Join the Agent Deck Discord Server!
Check out the Agent Deck community on Discord – hang out with 357 other members and enjoy free voice and text chat.
Clubes de lectura: progreso compartido, comentarios y contenido editorial.
Translation pending
SocialJavaScript · HTMLGitHub @LibreriaDeFuegoOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Loading the full README. The saved excerpt is shown below.
App social para clubes de lectura: progreso de lectura compartido, comentarios (texto, citas destacadas, notas de voz), spoilers colapsables y una sección editorial ("Recursos"). En producción: https://libris-relea.vercel.app — se despliega solo en cada push a main (proyecto libris en Vercel, equipo RELEA). Instalable como PWA desde Chrome en Android.Read the full README ↗
App social para clubes de lectura: progreso de lectura compartido, comentarios (texto, citas destacadas, notas de voz), spoilers colapsables y una sección editorial ("Recursos").
En producción: https://libris-relea.vercel.app — se despliega solo en cada push a main (proyecto libris en Vercel, equipo RELEA). Instalable como PWA desde Chrome en Android.
supabase/schema.sql para el modelo de datos y la sección Backend / Supabase más abajo para configurarlo.012_espanol_neutro.sql; se reafirma acá porque se volvió a colar voseo en features nuevas más de una vez. Antes de dar por terminado cualquier texto nuevo (UI, mensajes de error, copys de mail, comentarios que citen texto de pantalla), repasarlo contra esta regla.src/
app/ # rutas de Next.js (App Router)
page.js # / → "Mis clubes de lectura"
login/ page.js + actions.js (signIn/signUp/signOut, Server Actions)
recursos/page.js # Guías/Cursos (contenido editorial curado)
descubrir/page.js # directorio de clubes públicos para unirse (antes club/otros)
club/[clubId]/page.js # detalle de un club puntual
club/[clubId]/comentarios/ # comentarios de ESE club, generales o por capítulo
club/[clubId]/preferencias/ # nombre, visibilidad, libro, administradores y salir de ESE club
club/[clubId]/capitulos/ # gestión de capítulos y volúmenes (solo administradores)
club/nuevo/ # crear o unirse a otro club (multi-club)
unirse/[clubId]/ # pantalla de invitación (link para compartir)
auth/callback/ # aterrizaje de los links que manda Supabase por mail
actions/clubs.js # Server Actions: createClub, joinClub, addChapter,
# renameChapter, createVolume, renameVolume,
# promoteAdmin, demoteAdmin, joinClubFromInvite,
# leaveClub, updateClubPreferences,
# updateProgress, postComment
actions/media.js # Server Actions: uploadBookCover, postVoiceComment
manifest.js # genera /manifest.webmanifest (PWA)
components/
AppShell.jsx # shell con tab bar inferior (Recursos / Club / Descubrir / Perfil)
AvatarUploader.jsx # subir/cambiar la foto de perfil (mismo patrón que CoverUploader)
LoginForm.jsx, SignOutButton.jsx, NewCommentForm.jsx, InviteButton.jsx,
GoogleSignInButton.jsx, VoiceRecorder.jsx, CoverUploader.jsx,
ChapterPath.jsx (camino de capítulos completo + racha de lectura),
ClubActivityFeed.jsx, SwipeableSections.jsx
(carrusel deslizable entre Tu camino y Actividad del club),
PreferenciasIconButton.jsx (ícono con el punto de solicitudes pendientes,
lo usan /club/[clubId] y la hoja de Preferencias),
AddClubSheet.jsx (hoja de "Crear un club nuevo" / "Unirme con un link"),
ClubMembersModal.jsx (lista de integrantes de un club, desde "Mis
clubes de lectura"), FollowButton.jsx (seguir/dejar de seguir,
compartido entre Perfil y esa lista)
ServiceWorkerRegistration.jsx
screens/ # pantallas de producto (usan el design system + props con datos reales)
lib/
supabase/ client.js, server.js, middleware.js (@supabase/ssr)
activeClub.js (multi-club: clubes del usuario + libro activo de un club),
clubDetail.js (capítulos/progreso de un club — getClubHeroExtras para
/club/[clubId] completo, getClubProgressSummary para la tarjeta chica
de cada club en "Mis clubes de lectura"),
orderChapters.js (orden y nombre de capítulos, agrupados por volumen),
heroProgress.js (% / etiqueta / pips, a partir de capítulos o página —
lo usan la tarjeta de cada club y el camino de capítulos),
coverFrame.js (matemática del encuadre de portada — escala, clamp, presets,
persistencia como 4 números normalizados; portada de cover-framer.js),
friendlyError.js (traduce errores de Postgres/RLS a mensajes en español),
profileData.js (las 4 consultas del Perfil — profile/stats/actividad/sigo,
compartidas entre /perfil y /perfil/[profileId]),
formatRelativeTime.js, safeNext.js, authProviders.js, requireUser.js
design-system/ # sistema de diseño Libris (tokens + 18 componentes reutilizables)
tokens/ colors.css, typography.css, spacing.css, effects.css
components/ core/ forms/ content/ navigation/ feedback/
proxy.js # refresca la sesión de Supabase en cada request (convención Next 16)
public/
icons/ # íconos PWA (placeholders, ver nota abajo)
sw.js # service worker mínimo (cache del app shell)
design-reference/ # specimens del design system, opciones de dirección visual descartadas,
# y el UI kit clickeable original — documentación, no se importa en la app
.claude/skills/libris-design/ # el design system empaquetado como skill de Claude Code
supabase/
schema.sql # esquema Postgres: perfiles, clubes, membresías, libros,
# capítulos, progreso, comentarios — con Row Level Security
migrations/ 002_app_additions.sql (trigger de perfil + políticas de insert),
003_fix_club_creation.sql (el creador puede leer su club),
004_invitaciones.sql (datos del club para la pantalla de invitación),
005_descubrimiento_voz_editorial.sql (buckets de Storage,
funciones de descubrimiento y tabla editorial),
006_multiclub.sql (poder salir de un club),
007_descubrir_clubes.sql (directorio de clubes públicos),
008_recursos_sin_autores.sql (Descubrir → Recursos, se saca la categoría Autor),
009_administradores_y_volumenes.sql (administradores del club,
capítulos con nombre propio agrupados en volúmenes),
010_numeracion_por_volumen.sql (el número de capítulo es único
por volumen, no por todo el libro),
011_progreso_por_pagina.sql (progreso por capítulo o por página,
porque cada quien puede tener una edición distinta),
012_espanol_neutro.sql (mensajes de los triggers y contenido editorial
a español latinoamericano neutro, sin voseo),
013_encuadre_portada.sql (cover_crop + cover_has_title en books,
y books pasa a editarse solo por administradores),
014_postulaciones.sql (join_mode además de público/privado,
tabla club_join_requests, discover_public_clubs con clubes
"con solicitud"),
015_perfil_de_usuario.sql (bio + bucket de avatares, tabla
follows, funciones profile_activity y profile_stats),
016_fotos_de_lectura.sql (tabla posts + bucket post-photos,
profile_activity ahora también trae las fotos),
017_nombre_de_usuario.sql (username único en profiles,
función is_username_available, el trigger de alta
de cuenta lo guarda si vino en el registro),
018_usuario_admite_puntos.sql (el username también
permite puntos, sin empezar/terminar/repetirlos),
019_citas_para_instagram.sql (quote_style en comments,
profile_activity ahora también lo trae),
020_inicio_novedades.sql (función recent_activity:
citas y fotos de todos los usuarios, para Inicio),
021_imagen_de_cita_guardada.sql (bucket quote-cards,
comments.quote_image_url, profile_activity y
recent_activity ahora también la traen),
023_resena_final_y_solo_capitulos.sql (reading_progress.
finished_at, comments.title + kind 'review',
se saca "General del libro" de Comentarios),
024_editar_borrar_resena.sql (políticas update +
delete en comments, para editar/borrar tu propia
reseña final),
025_editar_borrar_solo_resenas.sql (angosta esas
políticas a kind = 'review' — la 024 había
quedado general, para cualquier comentario),
026_editar_foto_propia.sql (política de editar
el texto de tus propias fotos en posts —
borrar ya existía desde la 016),
027_editar_borrar_citas.sql (amplía las políticas
de la 025 para incluir también kind = 'quote'),
028_editar_borrar_comentarios_y_voz.sql (las
mismas políticas, ahora los 4 kind: review,
quote, text, voice),
029_me_gusta.sql (comment_likes + post_likes,
like_count/liked_by_me en profile_activity y
recent_activity),
030_responder.sql (comments.parent_comment_id,
profile_activity excluye las respuestas),
031_compartir_en_inicio.sql (comments.
shared_to_feed, recent_activity trae texto/voz
compartidos),
032_responder_en_inicio_y_perfil.sql (columna
"replies" en profile_activity/recent_activity,
el hilo de la reseña/cita),
033_comentarios_en_fotos.sql (tabla post_comments,
"replies" también lleva los comentarios de
cada foto),
034_agrupar_respuestas.sql (comments.
reply_to_id, para agrupar visualmente una
respuesta debajo del comentario puntual al
que le contesta)
verificar-setup.sql # chequea que las migraciones estén aplicadas
npm install
npm run dev
Creá un proyecto gratis en supabase.com.
En el SQL Editor del proyecto, pegá y ejecutá el contenido de supabase/schema.sql — crea las tablas y las políticas de Row Level Security (cada usuario solo ve/edita lo de sus propios clubes).
Copiá .env.local.example a .env.local y completá con los valores de Project Settings → API:
NEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_ANON_KEYAmbas son claves públicas (protegidas por RLS) — nunca commitear .env.local ni exponer la service_role key.
src/lib/supabase/client.js (Client Components), server.js (Server Components / Route Handlers) y src/proxy.js (refresco de sesión) ya están armados con @supabase/ssr — listos para usarse una vez cargadas las variables de entorno.
Verificado funcionando de punta a punta contra Supabase real: registro/login, crear club con su primer libro, actualizar progreso (persiste), publicar comentarios (texto, cita, spoiler), y persistencia entre sesiones.
Setup de la base: correr en orden supabase/schema.sql y después cada archivo de supabase/migrations/ por número. supabase/verificar-setup.sql chequea que estén todas aplicadas (todas las filas deben decir OK). Para probar sin esperar mails, desactivar Confirm email en Authentication → Sign In / Providers; antes de abrir al público conviene reactivarlo (la ruta /auth/callback ya está lista para recibir esos links).
Ya configurado y funcionando en producción. signInWithGoogle en src/app/login/actions.js y el botón en GoogleSignInButton.jsx. El botón solo se muestra si el proveedor está habilitado en Supabase — src/lib/authProviders.js consulta /auth/v1/settings y no lo renderiza si Google está apagado, para que nunca quede un botón que falla al tocarlo.
Para activarlo en otro entorno: crear credenciales OAuth en Google Cloud Console (redirect URI = https://<proyecto>.supabase.co/auth/v1/callback), cargarlas en Supabase (Authentication → Sign In / Providers → Google), y poner el Site URL + Redirect URLs del proyecto apuntando al dominio de la app. No hace falta tocar código ni redesplegar.
Pendiente de marca: la pantalla de consentimiento de Google muestra el dominio de Supabase en vez de "Libris". Para que diga Libris hace falta un dominio propio + el add-on Custom Domain de Supabase (pago) + verificación de la app en Google.
/inicio) — el feed general: citas y fotos de todos los usuarios de Libris, más recientes primero. Ver "Inicio: feed de novedades" más abajo./recursos) — contenido curado en dos secciones: Guías y Cursos. Se sacó la categoría "Autor" (migración 008)./) — ya no es el detalle de un club: es "Mis clubes de lectura", la lista completa de los clubes en los que participás (los hayas creado o no), cada uno con su actividad más reciente. Tocar uno lleva a /club/[clubId], el detalle real (libro, progreso, comentarios)./perfil) — el propio perfil, que se puede seguir. La pestaña muestra la foto real de la persona en vez de un ícono (viene de layout.js, que busca el perfil propio server-side y se lo pasa a AppShell).Descubrir (/descubrir) ya no tiene pestaña propia — se llega desde el adelanto que tiene "Mis clubes de lectura" (la búsqueda y "Ver todo en Descubrir"). La ruta sigue igual, es el directorio de clubes públicos para unirse.
Cada perfil tiene foto (bucket avatars, igual de público que las portadas de libro), una bio corta, tres números (libros / seguidores / siguiendo) y un feed de "actividad": cada comentario que esa persona escribió (y, desde la migración 016, cada foto que compartió), con una imagen grande de fondo y el texto anclado abajo — se toca la tarjeta para desplegar el texto completo.
/perfil es el propio (con "Editar perfil": nombre, bio, foto). /perfil/[profileId] es el de cualquier otra persona — se llega tocando un nombre en la lista de miembros de Preferencias. Las dos rutas comparten las mismas cuatro consultas (src/lib/profileData.js).follows (quién sigue a quién), pública igual que en cualquier red social — no requiere aprobación, a diferencia de las postulaciones a clubes.profile_activity (security definer) solo muestra lo que comentaste en un club abierto o "con solicitud" a cualquiera; lo de un club privado (invite) solo se lo muestra a quien ya es miembro de ese club — o a ti mismo, mirando tu propio perfil, sin excepción. Los comentarios marcados spoiler no aparecen nunca acá. profile_stats (libros/seguidores/siguiendo) sigue el mismo criterio para el conteo de libros.El encabezado del perfil pasa a ser una estantería con el avatar encima (migración 045). Antes: avatar chico a la izquierda, los tres números a la derecha en fila. Ahora: arriba de todo, una fila de portadas — los libros que la persona ya TERMINÓ — una al lado de la otra en orden, con esquinas redondeadas y espacio entre sí; si no entran todas, la fila entera se desliza al costado (como el carrusel de fotos de un comentario). El avatar queda centrado, superpuesto sobre el borde inferior de esa fila (ProfileHero, PerfilScreen.jsx) — sin libros leídos todavía, se muestra solo, sin la fila. Nombre, usuario, bio y los tres números quedan centrados debajo; el botón de seguir (o el disparador de "Foto", en tu propio perfil) pasa a su propia fila centrada, ya no al lado del nombre. Se armó primero como mockup (artifact aparte, con los tokens reales de la app) y se fue ajustando en varias vueltas antes de llevarlo al código: al principio los libros del fondo iban desenfocados y girados como un collage — se cambió a una fila derecha, nítida, con más separación entre portadas y esquinas redondeadas, siguiendo indicaciones puntuales sobre una referencia visual.
stats.book_count (los tres números de siempre) — ese cuenta cualquier libro con progreso registrado (reading_progress), esté terminado o no. La señal correcta de "lo terminé" ya existía: una reseña final (comments.kind = 'review', solo se puede dejar después de marcar el último capítulo y tocar el nodo de FIN en Tu camino). Nueva función profile_books_read (security definer, misma regla de visibilidad que profile_activity/profile_stats) trae, para cada libro distinto reseñado por esa persona, la portada más reciente con la que se reseñó — si leyó el mismo libro en dos clubes, cuenta una vez.PreferenciasScreen, la tarjeta de club en MisClubesScreen) donde el título ya se muestra aparte como texto y el color sólido de respaldo alcanza, acá la portada es lo único que hay: sin el título encima, un libro sin portada sería un rectángulo de color sin ninguna pista de cuál es. BookCover (PerfilScreen.jsx) le suma el título, con un degradé oscuro abajo para que se lea, solo cuando falta la portada real.no-scrollbar (globals.css) al contenedor — la misma que ya usa el carrusel de "Tu camino"/"Actividad del club" — que esconde la barra sin tocar el scroll en sí: se sigue deslizando exactamente igual, con el dedo o el mouse, solo que sin la barra."Mi biblioteca" (migración 046) — la estantería del encabezado y el número "Libros" pasan a ser la puerta de entrada a una pantalla propia (/perfil/biblioteca, o /perfil/[profileId]/biblioteca para la de otra persona), no un adorno: se llega tocando cualquiera de los dos (ProfileHero y el bloque de estadísticas quedan envueltos en un <Link> a libraryHref). Ahí se ve la lista completa, en grilla de tres columnas, con la portada, el título y el autor de cada libro.
profile_books_read solo traía libros con una reseña final DENTRO de un club — no había forma de anotar algo leído por fuera de Libris. Se pensó primero como una posible sección nueva en la barra inferior, pero se descartó a propósito: el README ya documenta que "Descubrir" se sacó de esa barra por ser una pestaña de más, así que agregar una quinta para esto iba en contra de ese mismo criterio. Quedó, en cambio, como una pantalla a la que se entra desde el Perfil.personal_books (profile_id, title, author, cover_url) para lo agregado a mano, y bucket personal-book-covers — misma visibilidad que posts (migración 016): contenido personal, no atado a ningún club, así que se ve siempre que se puede ver el perfil, sin el chequeo de club privado que sí les cabe a los libros que vienen de una reseña.profile_books_read se redefinió para traer, además, los agregados a mano — un union all entre la reseña final de club (como antes) y personal_books, con un campo nuevo source ('club' o 'personal') para poder distinguirlos: en Mi Biblioteca hay chips para filtrar por "Todos" / "De mis clubes" / "Agregados por mí", y el botón de borrar (una × sobre la portada) solo aparece en los agregados a mano — una reseña de club se administra desde el club, no desde acá.stats.book_count (el número "Libros" de las tres estadísticas) pasa a salir siempre de booksRead.length en vez del valor crudo de profile_stats — así el número que se toca siempre coincide con la cantidad que se ve al entrar.CoverCropModal) — tampoco acá hay una única proporción correcta — y el mismo patrón de PostComposer: el recorte se resuelve del lado del navegador y se guarda en estado local; recién al tocar "Agregar" se suben la portada y se inserta la fila juntas, en una sola acción de servidor (addPersonalBook).Fechas de lectura y Recuento del año (migración 047), al estilo del "Year in Books" de Goodreads: cada libro de Mi Biblioteca puede tener cuándo se empezó y cuándo se terminó, y desde ahí se entra a una pantalla nueva con el resumen de cuántos se terminaron en cada año.
reading_progress: se le agregó started_at (se fija una sola vez, con el default de la columna — updateProgress, en clubs.js, hace upsert con un payload que nunca incluye ese campo, así que un conflicto nunca lo pisa) y se empezó a leer finished_at (ya existía desde la migración 023, para la opción "Terminado" de Actualizar progreso, pero hasta ahora nadie la leía). Un libro AGREGADO A MANO no tiene de dónde sacarlas: se piden directo en el formulario, las dos opcionales.EditPersonalBookForm — se abre tocando la portada de un agregado a mano, propio) dejar "Terminaste" en blanco significa "todavía lo estoy leyendo": ese libro sigue apareciendo en Mi Biblioteca (al principio de la lista, antes que los ya terminados), pero no cuenta para el Recuento de ningún año hasta que se le ponga una fecha de fin.profile_books_read (reviewed_at pasa a llamarse finished_at, más preciso) devuelve ahora started_at/finished_at para los dos orígenes — para un libro de club, finished_at sale de reading_progress.finished_at, con la fecha de la reseña como respaldo si por lo que sea faltara.RecuentoScreen (/perfil/biblioteca/recuento, o /perfil/[profileId]/biblioteca/recuento) no le pide nada nuevo al servidor — reusa la misma lista que ya trajo Mi Biblioteca (getLibraryData) y arma los años a mano, del lado del cliente, agrupando por el año de finished_at. Chips arriba para elegir el año (el actual siempre aparece, aunque tenga cero libros), grilla de portadas abajo con la fecha de cada una.Migración 048 — dos arreglos, apenas se probó lo de arriba. Editar la fecha de un agregado a mano parecía funcionar (sin ningún error) pero no quedaba guardado; y no había ninguna forma de corregir la de un libro de club.
personal_books (migración 046) tenía policy de select/insert/delete, pero nunca de UPDATE. Con RLS activado y ninguna policy que lo permita, Postgres no tira error — el UPDATE simplemente no toca ninguna fila (PostgREST no distingue eso de "no encontró la fila"), así que el formulario mostraba éxito y cerraba, pero nada cambiaba. Se agregó la policy que faltaba.reading_progress sí tenía su policy de update — lo que faltaba era el dato para apuntarlo. profile_books_read ahora también devuelve club_book_id (null en un agregado a mano), y tocar la portada de un libro de club, en la propia biblioteca, abre EditClubBookDatesForm — mismo modal que el de un agregado a mano pero sin título/autor (esos son del libro del club, no se editan desde acá), llama a la acción nueva updateClubBookDates.Desde el propio perfil se puede compartir una foto de lo que se está leyendo — de la galería o sacada en el momento con la cámara. Antes de subirla pasa por el mismo recorte estilo Instagram que la foto de perfil (PhotoCropModal, src/components/PhotoCropModal.jsx), acá en proporción vertical 3:4 en vez de cuadrada, con un texto corto opcional.
PostComposer pasa a recibir profile (antes no necesitaba ningún dato del perfil) para mostrar el avatar de la barra.
surface-sunken, sin borde) no convenció — leía como un campo deshabilitado, no como algo para tocar. Se probaron 6 alternativas de COLOR más sobre esa misma barra (tarjeta blanca con borde, tinte coral clarito, solo contorno, relleno coral sólido, línea simple sin pastilla, tarjeta flotante con sombra) y quedó la tarjeta blanca: mismo tratamiento que ya usan las tarjetas y el menú del perfil (surface-card + borde border-default + shadow-sm) — se distingue del fondo de la página sin necesitar un color de relleno aparte.Solo texto, y citas del perfil (migración 049). Tocar la tarjeta entera abría directo el selector de fotos del celular — no dejaba escribir sin elegir antes una foto. Ahora tocar la tarjeta (avatar + texto fantasma) abre la ventana para escribir — al principio con una fila de íconos adentro (AttachmentToolbar) para adjuntar una foto, un GIF o pasar a una cita destacada; la migración 052 (más abajo) reemplazó esa fila por las mismas cuatro pestañas de tipo que ya usa el panel de "Agregar" de Tu camino.
Una foto (no GIF) sigue pasando por el recorte vertical (3:4) antes de volver a la ventana, ahora con la vista previa y una × para sacarla sin perder el texto ya escrito. Un GIF (migración 039) sigue sin pasar por el recorte, por la misma razón de siempre: PhotoCropModal dibuja en un <canvas>, que solo captura un frame.
Elegir "Cita" en cualquier momento abandona el texto que se estaba escribiendo — son dos publicaciones distintas (posts vs. profile_quotes), no se pueden mezclar en una sola.
Publicar solo texto es nuevo — antes toda fila de posts exigía una foto (image_url not null); ahora puede ser solo texto, solo foto, o las dos cosas (posts_content_check, constraint nueva: lo único que no vale es las dos vacías). createPost/updatePost (posts.js) y ActivityCard (el bloque de imagen no se dibuja si no hay ninguna) se actualizaron para ese caso.
Citas destacadas fuera de un club, nuevo (profile_quotes). Las citas que ya existían (comments.kind = 'quote') cuelgan siempre de un club_book_id — se citan desde ADENTRO de la conversación de un libro puntual de un club, esa columna es not null. La barra del perfil no tiene ese contexto, así que es una tabla aparte: se elige un libro (de una lista — los de tus clubes, terminados o no, más los agregados a mano en Mi Biblioteca, profile_quotable_books) y se copian sus datos (título/autor/portada) directo en la fila de la cita, sin relación con books ni personal_books — son dos orígenes distintos, no hay una única tabla a la que apuntar.
"Otro libro" (migración 051) — no todo lo que se quiere citar está en esa lista (un libro que no leíste con tu cuenta de Libris, o del que solo tenés una copia física). QuoteComposer suma una segunda forma de elegir: título + autor a mano, con una portada opcional recorteada con CoverCropModal (mismo componente que ya usa Mi Biblioteca — tampoco acá hay una única proporción). La portada, si hay, se sube al mismo bucket personal-book-covers (mismo límite de 5 MB) recién al publicar, igual que el resto de los blobs que se sostienen en estado local hasta el envío final — pero no queda ninguna fila en personal_books: es solo para esta cita puntual, no se agrega a Mi Biblioteca ni vuelve a aparecer en la lista de "tus libros" la próxima vez.
Alcance elegido a propósito, más chico que una cita de club: se puede publicar, borrar y dar "me gusta" (, mismo criterio que ) — todavía no tiene edición, hilo de respuestas ni se puede repostear (una cita de perfil nunca puede aparecer reposteada: apunta a , no a ). / la suman como una rama más (, siempre ) — ese nulo es lo que usa para distinguir una cita de perfil de una de club y mostrar la versión simplificada (sin , solo el corazón).
Estética propia para las citas (migración 050), adentro de la app. El tratamiento genérico de una cita sin imagen guardada (portada de fondo + texto en cursiva, igual que una foto) pasa a ser uno más entre varios — ahora se elige un estilo propio al publicar, tanto para una cita de club como para una del perfil. No confundir con quote_style/quoteCard.js (la tarjeta que se arma como imagen para compartir en Instagram, un sistema aparte con sus propios 3 estilos) — esto es cómo se ve la cita DENTRO del feed de Libris.
card_style/card_color, nuevas en comments (solo aplican con kind = 'quote') y en profile_quotes — nullable, sin default a propósito: una cita ya publicada antes de esta migración no tiene ninguno de los dos, y sigue cayendo en el tratamiento genérico de siempre (ActivityCard solo usa el estilo nuevo si card_style no es null) — nada que migrar para atrás. Cuando SÍ hay una tarjeta-imagen guardada (quote_image_url, citas de club), esa sigue ganando siempre — es lo más fiel a lo que se eligió al publicar.CardStylePicker (selector + vista previa en vivo, con el mismo QuoteFeedCard que va a mostrar el feed) se sumó en los tres lugares donde se escribe o edita una cita: NewCommentForm/EditQuoteModal (citas de club, como una sección aparte de "Estilo de la tarjeta") y QuoteComposer (citas del perfil). Elegir un estilo por primera vez arrastra el color por default de ESE estilo — pero solo mientras no se haya tocado el color a mano; en cuanto se toca una vez, queda fijo y cambiar de estilo ya no lo pisa.Se sacó el "Estilo de la tarjeta" para Instagram al crear o editar una cita (sin migración). Con el estilo propio del feed ya andando (arriba), quedaban dos selectores de "estilo" en la misma pantalla con nombres parecidos — el viejo (quote_style/quote_image_url, migración 019: cover/dark/editorial, pensado para descargar como imagen y compartir en Instagram) y el nuevo (card_style/card_color, cómo se ve la cita DENTRO del feed de Libris). Se sacó el viejo por completo de NewCommentForm/EditQuoteModal: ya no hay forma de elegir ni regenerar una tarjeta-imagen al publicar o editar una cita — solo queda el selector de "Cómo se ve en el feed".
src/lib/quoteCard.js (el motor que dibuja la tarjeta en un <canvas>), QuoteCardPreview.jsx, DownloadQuoteImageButton.jsx y el CommentBody de ComentariosScreen.jsx quedan intactos — una cita que ya tenía guardada su tarjeta de Instagram (de antes de este cambio) la sigue mostrando y se sigue pudiendo descargar exactamente igual que siempre. Simplemente ninguna cita nueva puede generar una desde ahora.updateQuote (clubs.js) todavía mandaba quote_style/quote_image_url en su update, tomados del formulario — al sacar esos campos del formulario, las dos variables hubieran quedado siempre en null, así que editar cualquier cita (aunque sea para corregir una palabra o cambiar el color del feed) hubiera borrado la tarjeta de Instagram ya guardada de esa cita, junto con su archivo en el bucket quote-cards. Se corrigió sacando esos dos campos del update por completo — editar una cita ya no toca para nada su tarjeta vieja, la deja como estaba.profile_activity ahora hace un union all entre comments y posts. Como una foto no depende de ningún club, es visible siempre que se puede ver el perfil (mismo criterio que "Seguir").scroll-snap.Novedades se sacó de la app (pestaña y ruta /novedades eliminadas) — mostraba el mismo feed editorial que ya está en Recursos, quedaba redundante. Su reemplazo llegó con la migración 020: la pestaña Inicio (ver abajo).
Pestaña nueva, la primera de la barra de abajo (ícono de casa). Es un feed de actividad de todos los usuarios de Libris, no solo de tus clubes.
recent_activity (security definer) es la función nueva que arma el feed — mismo criterio de visibilidad de club que ya usaba profile_activity: las citas de un club abierto o "con solicitud" se ven siempre, las de uno privado (invite) solo si sos miembro. Las fotos son públicas siempre, como ya lo eran. A diferencia de profile_activity (la actividad de un perfil puntual), esta es general — no filtra por quién sigue a quién, es la misma lógica sin restricción por relación que ya se usa en el buscador de Descubrir.ActivityCard se movió del Perfil a src/components/ActivityCard.jsx, compartido entre las dos pantallas — con una prop author opcional que agrega la fila de nombre y foto solo cuando hace falta (Inicio la usa, Perfil no, porque ahí ya se sabe de quién es la actividad).Además del nombre para mostrar, cada perfil tiene un @usuario propio y único (minúsculas, letras/números/guion bajo/punto, 3 a 20 caracteres, sin empezar/terminar/repetir el punto — regla compartida en src/lib/username.js) — sirve para distinguir a dos personas con el mismo nombre en el buscador y en el perfil.
username = null a propósito — nada de asignarles uno sin avisar. La próxima vez que abren la app, el middleware (src/lib/supabase/middleware.js) las manda a /elegir-usuario, una pantalla obligatoria que no se puede saltear hasta guardar uno. Después de eso se puede cambiar en cualquier momento desde "Editar perfil".is_username_available(check_username) es una función security definer que se puede llamar sin sesión (para el registro, antes de que exista la cuenta) — devuelve solo true/false, nunca datos de la tabla. La usan las tres pantallas (registro, /elegir-usuario, editar perfil) a través del mismo componente, src/components/UsernameField.jsx.libris_username_set) que se pone la primera vez que se confirma que la cuenta tiene uno, y evita la consulta en las visitas siguientes.Una sola barra de búsqueda arriba del directorio de /descubrir busca dos cosas a la vez:
@usuario entre todos los usuarios de Libris (no solo compañeros de club), con dos consultas directas a profiles desde el navegador (src/lib/supabase/client.js, primer uso del cliente de Supabase del lado del cliente en la app — hasta ahora todo pasaba por Server Actions/Components) — dos consultas separadas en vez de una combinada con .or(), para no tener que armar a mano un texto de filtro que con comas o paréntesis en lo que alguien escriba podría romper. Funciona sin ninguna función nueva porque los nombres de perfil ya son legibles para cualquier usuario logueado (política profiles are readable by authenticated users, la misma que permite compartir un link de perfil).Con la búsqueda vacía se ve el directorio de siempre; al escribir, aparecen dos secciones ("Clubes" / "Personas") con lo que haya, cada una oculta si no tiene resultados. Hay un debounce de 300ms para no mandar una consulta por letra.
Pendiente, a propósito: hoy cualquier persona puede aparecer en esta búsqueda — no existe (todavía) una opción de perfil privado/público como en Instagram. Cuando se agregue esa opción, va a necesitar su propio flujo de "solicitud para seguir" con notificación (como ya existe para unirse a un club "con solicitud", migración 014) — se dejó pendiente a propósito, decisión explícita del dueño del producto.
De vuelta con una puerta de entrada. En algún momento /descubrir perdió su pestaña propia de la barra de abajo (quedó reemplazada por Recursos — ver AppShell.jsx) y se quedó sin ninguna forma de llegar a ella desde la app: la ruta seguía viva, la búsqueda seguía funcionando, pero nadie podía tocar nada para llegar ahí. Se probaron 3 mockups de dónde ponerle una entrada (barra completa en Inicio, ícono en el encabezado de Inicio, ícono en Mis clubes) y se construyeron dos:
Link a /descubrir con el mismo look que un Input (borde, fondo, radio) y una lupa adentro — parece un campo de búsqueda pero es un botón: tocarlo lleva a /descubrir, no filtra el feed ahí mismo. Se optó por parecerse al campo real para que se entienda de un vistazo qué hace, aunque el gesto real (tocar, no tipear) sea distinto.IconButton con una lupa, al lado del "+" de siempre — mismo lugar donde ya se agrega un club, porque buscar uno es la acción hermana de esa. A diferencia del botón de Inicio, acá es un ícono solo (sin texto), consistente con que el "+" tampoco lo lleva.Los dos son solo un Link/router.push('/descubrir') — cero lógica nueva, la pantalla de destino ya existía tal cual.
Las citas destacadas (kind = 'quote') ya existían desde antes; lo nuevo es poder exportarlas como imagen lista para publicar.
comments.quote_style), no solo al momento de descargar: la tarjeta se ve siempre igual, la vuelva a descargar quien la publicó o aparezca así en su perfil más adelante.quote_style = null y no muestran el botón).<canvas> (src/lib/quoteCard.js), a partir del texto y el estilo guardados más los datos del libro/club/persona ya disponibles. Mismo enfoque sin librerías externas que ya usa imageProcessing.js para las fotos.public/logo-libris.png para el estilo Editorial (fondo claro) y una variante nueva en crema, public/logo-libris-cream.png, para Portada y Oscuro (fondo oscuro) — generada con la misma técnica de recoloreado por píxel que ya se usó para los íconos PWA.Vista previa en vivo al publicar (src/components/QuoteCardPreview.jsx) — antes de tocar "Publicar", debajo del selector de estilo aparece el dibujo real de la tarjeta (no una aproximación), armado con el mismo renderQuoteCard que se usa para descargar. Se regenera sola con un debounce de 400ms cada vez que cambia el texto o el estilo elegido.
Al principio la tarjeta no se guardaba en ningún lado — se volvía a dibujar cada vez que alguien quería descargarla, y el feed (Inicio, Actividad del perfil) seguía mostrando la cita con el tratamiento genérico de siempre (la portada del libro de fondo + el texto superpuesto), sin importar qué estilo se hubiera elegido. Ahora, al publicar, el navegador arma la tarjeta (la misma vista previa de arriba) y la sube al bucket quote-cards — el feed muestra esa imagen tal cual, en el formato exacto que la persona eligió.
comments.quote_image_url guarda la URL pública. Es un "mejor esfuerzo": si la subida falla (o la portada del libro no cargó), la cita se publica igual, solo que sin imagen — y el feed la muestra con el tratamiento genérico de antes, el mismo que ya usan las citas publicadas antes de esta migración.ActivityCard (Perfil e Inicio): cuando una cita tiene quote_image_url, esa imagen ES la tarjeta completa — no se vuelve a poner el texto ni el libro encima, sería redundante (ya está dibujado adentro). Solo un pie de foto chico ("Publicó una cita · hace X") y, al expandir, los mismos botones de descargar y ver los comentarios.DownloadQuoteImageButton descarga la imagen guardada directamente (fetch + blob) cuando existe, en vez de regenerarla — así lo que se descarga es exactamente lo que se ve en el feed, sin ninguna diferencia de un píxel. Sin quote_image_url (citas viejas), sigue regenerándola al vuelo como antes.profile_activity y recent_activity ahora también devuelven quote_image_url (mismo patrón de DROP + CREATE que las migraciones anteriores).Saltos de línea: escribir con Enter dentro de una cita (o de un comentario común) ahora se respeta tal cual en todos lados — en Blockquote, en las tarjetas de Actividad (Perfil e Inicio) y en la imagen exportada de quoteCard.js. Antes el salto de línea se perdía: React/CSS colapsan \n a un espacio por default (hacía falta white-space: pre-wrap en cada lugar que muestra el texto), y wrapText en quoteCard.js partía el texto entero por espacios sin distinguir un salto de línea puesto a propósito de uno que el envoltorio automático seguiría poniendo solo. Ahora wrapText separa primero por párrafo (cada \n) y recién ahí envuelve cada uno por ancho.
Los tres estilos quedaron en 3:4, la misma proporción que ya usan las fotos de "lo que estás leyendo" — antes Portada y Editorial eran 4:5 y Oscuro cuadrado, así que dos citas seguidas en el feed (o una cita y una foto) no medían lo mismo. quoteCard.js ahora dibuja los tres a 1080×1440 (una sola constante HEIGHT, no una por estilo); como el centrado del texto ya se calculaba a partir del alto del lienzo, no hizo falta retocar cada estilo a mano. La tarjeta de Actividad (ActivityCard) pasó de una altura fija (440px) a aspect-ratio: 3 / 4, para que el recuadro que envuelve cualquier imagen del feed —cita o foto— tenga siempre esa misma proporción.
Las citas con imagen guardada de antes de este cambio quedaron con su proporción vieja (Portada/Editorial en 4:5, Oscuro cuadrado) grabada para siempre en el JPEG — no hay forma de "reconvertirlas" a 3:4 sin volver a dibujarlas. Mostrarlas con background-size: cover en el marco nuevo las agranda y les corta los costados, cortando el propio texto de la tarjeta (bug real, reportado y visto en captura). Por eso, específicamente cuando hay quote_image_url, el fondo usa contain en vez de cover — se ve completa siempre, sea cual sea su proporción real, a costa de un borde de color liso a los costados si no coincide con 3:4 exacto. Las citas nuevas (ya nacen en 3:4) llenan el marco igual que con cover, porque coinciden exacto con el contenedor.
Los comentarios de un club ya no tienen sección "General del libro" — se sacó a propósito, para reforzar comentar capítulo a capítulo (que era la mitad de las opciones y quedaba floja frente a la otra mitad). ComentariosScreen arranca siempre en el primer capítulo; si el club todavía no tiene ninguno, muestra un aviso en vez de un formulario sin dónde publicar.
reading_progress.finished_at queda con la fecha, el progreso pasa a 100% (ancla el capítulo al último de la lista, o a la página total ya registrada si el libro no tiene capítulos) — y se abre FinalReviewModal: título obligatorio + una reseña, tan larga como quieras.comments, kind = 'review'), pero siempre del libro entero — chapter_id queda null a la fuerza (constraint comments_review_shape_check), nunca se cuelga de un capítulo puntual. Una por persona por libro, por convención de la propia UI (FinalReviewModal precarga la que ya exista, vía reviewId, para editarla en vez de duplicarla) — no hay un constraint en la base que lo obligue.BookReviewCard (src/components/BookReviewCard.jsx) es el bloque visual: el título en un panel de color (--gold-500, fijo por ahora) y, fundido con él, la portada del libro —la que ya tiene el club, no una foto que suba la persona— flotando chica con una sombra proyectada en diagonal y un brillo de luz por encima. Mismo tratamiento que se probó y aprobó primero como mockup ("Identidad de Citas"), ahora aplicado a portadas reales: como el ancho/alto salen del tamaño natural de la imagen (max-width/max-height, sin recortar ni deformar), cualquier proporción de tapa se ve completa. Lo usan tanto ComentariosScreen (arriba de todo, antes de los capítulos) como ActivityCard (Inicio y Perfil) y la vista previa en vivo de FinalReviewModal.ActivityCard.recent_activity ahora también trae las reseñas (antes solo citas destacadas + fotos), con el mismo criterio de privacidad de club que ya usaban las citas. profile_activity y recent_activity ahora devuelven title (antes solo lo tenían las citas destacadas, que no lo usan — queda null para ellas).Sobre la foto de las personas leyendo (migración 016): esto NO le tocó nada — sigue tal cual, recorte 3:4, sin título, con el tratamiento genérico de siempre en el feed. La identidad con portada + sombra es específica de la reseña final, donde la imagen es la tapa de un libro real (con su propia proporción, conocida), no una foto que puede traer cualquier fondo.
Junto al nombre de quien publicó una reseña (kind = 'review'), un menú de 3 puntos (src/components/PostMenu.jsx) ofrece Editar reseña / Eliminar reseña — solo a quien la escribió, nunca a nadie más.
comments no tenía política de update ni de delete (solo select + insert) — nadie podía tocar lo que ya había publicado, de ningún tipo. La migración 024 agregó ambas, pero de entrada quedó general (cualquier kind, no solo reseñas) — eso no correspondía, porque ni la UI ni la limpieza de Storage (una cita borrada dejaría huérfana su imagen guardada; una nota de voz, su audio) están pensadas para eso todavía. La migración 025 la angostó a kind = 'review' — es lo único editable/borrable hoy, a propósito.deleteBookReview (src/app/actions/clubs.js) borra la propia reseña — RLS la respalda, el .eq('profile_id', user.id).eq('kind', 'review') en la consulta es cinturón y tirantes, no la única traba.FinalReviewModal precargado (prop myReview), que ya sabía hacer esto desde la migración 023 (reviewId en el formulario evita duplicar la reseña) — ahora se le puede llegar también desde el propio menú, no solo por convención de una sola reseña por libro.ComentariosScreen), el menú abre el modal de edición ahí mismo — la pantalla ya tiene clubBookId y el libro a mano. En Inicio y Perfil (ActivityCard), "Eliminar" borra directo y "Editar" navega a los comentarios del club en vez de duplicar el modal — el feed no trae club_book_id en cada tarjeta.Mismo menú de 3 puntos (PostMenu), ahora también en tus propias fotos de "lo que estás leyendo" — en Inicio y en Perfil.
posts ya tenía política de borrar desde la migración 016 (no tenía UI ni acción para usarla). La 026 agrega la de editar — solo el texto (caption); la foto en sí no se reemplaza desde acá, mismo criterio que la reseña (ahí tampoco se reemplaza la portada).updatePost/deletePost (src/app/actions/posts.js). A diferencia de la reseña, acá deletePost sí limpia Storage: borra también el archivo de la foto (post-photos), porque el post es dueño de un único archivo propio.EditPostModal (src/components/EditPostModal.jsx) es la vista previa de la foto (fija) + el texto editable — mismo patrón liviano que un formulario, sin volver a pasar por el recorte.ActivityCard no repite el nombre (redundante, ya se sabe de quién es el perfil), la fila de encabezado con el menú usa justify-content: flex-end en vez de space-between — sin eso, un solo elemento en la fila quedaba pegado a la izquierda (mismo bug que tuvo la reseña, corregido ahí con el nombre siempre visible; acá se resolvió distinto porque no hacía falta agregar el nombre).Mismo menú de 3 puntos otra vez, ahora también en tus propias citas destacadas — en Comentarios del club, en Inicio y en Perfil.
kind = 'review' — las citas quedaron afuera a propósito en ese momento, porque borrar o cambiar el texto de una cita con imagen guardada (quote_image_url) dejaba el archivo viejo huérfano en Storage, algo que reseñas y comentarios de capítulo no tienen (la reseña no tiene archivo propio: usa la portada del libro). La 027 amplía esas políticas a también kind = 'quote', ahora que updateQuote/deleteQuote (src/app/actions/clubs.js) ya hacen esa limpieza.EditQuoteModal (src/components/EditQuoteModal.jsx) reusa el mismo selector de estilo y la misma vista previa en vivo (QuoteCardPreview) que ya tenía NewCommentForm al publicar — al guardar, el navegador vuelve a dibujar la imagen con el texto/estilo nuevos (renderQuoteCard) y updateQuote la sube y borra la vieja. Si la cita nunca tuvo imagen guardada (de antes de la migración 021, o si en su momento falló la subida), edita igual — sencillamente puede terminar generando una por primera vez.ActivityCard), mismo tratamiento que ya tenían las fotos — encabezado con PostMenu, flex-end en Perfil por la misma razón que las fotos.Los dos tipos que quedaban sin tocar — cerrando el círculo: ahora los cuatro tipos de comentario (reseña, cita, comentario de capítulo, nota de voz) se pueden editar o borrar desde su propio menú de 3 puntos.
kind = 'text'): EditCommentModal (src/components/EditCommentModal.jsx) edita el texto y el spoiler — sin archivo propio, no hay nada que limpiar en Storage. updateComment/deleteComment en src/app/actions/clubs.js.kind = 'voice'): EditVoiceModal (src/components/EditVoiceModal.jsx) deja escuchar el audio (con VoiceNotePlayer, de solo lectura) y edita la transcripción/resumen y el spoiler — el audio en sí no se regraba desde acá, para eso conviene borrar y grabar de nuevo. updateVoiceComment/deleteVoiceComment en src/app/actions/media.js (junto a postVoiceComment, que ya vivía ahí). Borrar sí limpia Storage — a diferencia de fotos y citas, acá voice_url ya es el path del archivo (no una URL pública: el bucket voice-notes es privado, cada reproducción usa una URL firmada), así que deleteVoiceComment lo borra directo, sin tener que desarmar una URL.kind in ('review', 'quote')) — la 028 la vuelve a abrir a los cuatro tipos, listados explícitamente (kind in ('review', 'quote', 'text', 'voice')) en vez de sacar la condición del todo, para que un tipo de comentario nuevo el día de mañana no quede editable de arrastre sin que alguien lo decida a propósito.recent_activity no los trae) y sí aparecen en el de Perfil (profile_activity no filtra por kind) pero ActivityCard ahí todavía no tiene el menú para estos dos tipos — quedó pendiente a propósito, se puede sumar si hace falta.Una fila de acciones bajo cada reseña, cita, comentario, nota de voz y foto — mockeada primero ("Botones de acción") y confirmada antes de construirla.
Me gusta (029) — el corazón + contador, en todo lo que aparece en Comentarios del club, Inicio y Perfil. Lo puede tocar cualquier miembro del club (no solo el dueño), a diferencia del resto de los botones de esta sección.
comment_likes (apunta a comments) y post_likes (apunta a posts) — cada una con su propia foreign key real, en vez de una referencia "polimórfica" sin garantía de integridad. La política de comment_likes respeta la privacidad del club (mismo criterio que is_club_member ya usa para los comentarios); post_likes es pública, como las fotos.toggleCommentLike/toggleShareToFeed/postReply en src/app/actions/clubs.js, togglePostLike en src/app/actions/posts.js — todas simples toggles: si ya existe tu fila, la borra; si no, la crea.profile_activity y recent_activity ahora también traen like_count y liked_by_me, calculados con un left join lateral a la tabla de likes que corresponda — no hace falta una consulta aparte. La pantalla de Comentarios del club, que no pasa por esas funciones, los trae aparte (comment_likes filtrado por los ids de esa página) y los mezcla a mano, en comentarios/page.js.LikeButton (src/components/LikeButton.jsx) es el componente — sin estado optimista propio: dispara la acción y el conteo real llega con el refresco normal de la página, mismo patrón que ya usan los botones de eliminar. Icon.jsx ganó una prop fill opcional (antes no existía) para poder pintar el corazón relleno cuando está marcado.Comentar (030) — el botón dice "Comentar", no "Responder" (hay una diferencia entre las dos palabras y "Comentar" es la que corresponde acá) — pero por dentro sigue siendo una respuesta: un comentario más (kind = 'text'), con parent_comment_id apuntando al original; hereda su club_book_id/chapter_id. No hace falta una tabla ni una política nueva para publicarla — la política que ya deja comentar en el club no distingue por kind. Se excluye de profile_activity (con parent_comment_id is null) para que no aparezca como una actividad propia repetida en el perfil de quien comentó.
Compartir (031) — solo tiene sentido, y solo aparece, en tus propios comentarios de capítulo y notas de voz: reseñas, citas y fotos ya aparecen siempre en Inicio, no hay nada que "compartir" ahí. Retoma la idea charlada antes de construirla: los comentarios del club se sentían atrapados adentro, sin forma de que quien los escribió los hiciera más públicos.
comments.shared_to_feed boolean not null default false — todo lo que ya existe queda exactamente como estaba (nada se comparte solo). recent_activity ahora también trae comentarios y notas de voz, pero solo si shared_to_feed = true (y no son una respuesta).ShareButton (src/components/ShareButton.jsx) se muestra condicionalmente desde quien usa el componente (isOwn && kind en text/voice), no hay nada del lado del botón que lo esconda por su cuenta.Los tres viven juntos en EngagementBlock (src/components/EngagementBlock.jsx): Me gusta + Comentar siempre, Compartir opcional (prop share), y debajo, el hilo de respuestas — las que ya hay, anidadas con su propio LikeButton chico, y el campo para escribir una nueva si se tocó "Comentar". Lo usan ReviewCard, cada comentario de capítulo en ComentariosScreen, y ahora también ActivityCard para reseñas y citas (ver migración 032). Compartir sigue siendo exclusivo de Comentarios del club — reseñas, citas y fotos no lo necesitan en ningún lado.
Dos ampliaciones charladas después de construir lo de arriba.
Comentar en Inicio y Perfil (032) — hasta acá, "Comentar" solo vivía en Comentarios del club. Se suma también donde se ven reseñas y citas fuera del club — no a comentarios de capítulo ni notas de voz, que ni siquiera aparecen ahí (o aparecen sin ese menú, ver migración 028).
profile_activity y recent_activity ahora devuelven una columna más, replies (jsonb) — el hilo completo de esa fila, armado con un jsonb_agg en un left join lateral (no hace falta una segunda consulta desde la pantalla). Mismas columnas que ya usaba EngagementBlock en Comentarios del club (id, body, created_at, profiles.display_name, like_count, liked_by_me), así que no hizo falta tocar ese componente — ActivityCard lo empezó a usar tal cual para reseñas y citas, pasándole activity.replies.postReply (ya existía, migración 030) no necesitó ningún cambio — no le importa desde qué pantalla se llama, solo el id del comentario al que se responde.Comentarios en las fotos (033) — las fotos nunca tuvieron dónde comentar, solo el texto que la propia persona escribe al publicarlas.
post_comments (no una fila más en comments, que exige un club_book_id que una foto no tiene). Más simple a propósito que el hilo de reseñas/citas: lista plana, sin responder a un comentario puntual ni su propio "me gusta" — se puede sumar después si hace falta.replies de profile_activity/recent_activity lleva, para las filas kind = 'photo', los comentarios de esa foto en vez de un hilo de respuestas — mismo lugar, para que ActivityCard no tenga que distinguir de dónde vienen.PhotoCommentsBlock (src/components/PhotoCommentsBlock.jsx) es el componente — mismo patrón visual que EngagementBlock (Me gusta + un botón que abre la lista y el campo para escribir) pero sin hilo anidado. postPhotoComment en src/app/actions/posts.js.Responder a un comentario puntual, estilo Instagram — cada comentario ya puesto (dentro de EngagementBlock: Comentarios del club, Inicio y Perfil) tiene su propio "Responder", chiquito, junto a su "Me gusta". No crea un segundo nivel de hilo — sigue siendo un solo nivel de anidamiento, todo cuelga del mismo original — solo precarga el campo compartido con @Nombre para que quede claro a quién le está contestando, mismo truco que usa Instagram por dentro (el @Nombre queda como texto plano en el comentario, no es un link ni una mención de verdad). No se tocó PhotoCommentsBlock — sigue sin "Responder" por comentario, a propósito, mismo criterio simplificado de la 033.
Textarea (src/design-system/components/forms/Textarea.jsx) pasó a React.forwardRef — antes no hacía falta, ahora EngagementBlock necesita enfocar el campo a mano al tocar "Responder" en una respuesta puntual.Agrupar visualmente esa respuesta (migración 034) — al principio, responderle a una respuesta puntual quedaba igual que cualquier otro comentario: al final de la lista, sin nada que la distinga salvo el @Nombre en el texto. Ahora se agrupa debajo del comentario al que le contesta, con más sangría y un avatar más chico — como un conjunto propio, no una fila más.
comments.reply_to_id — distinto de parent_comment_id (que sigue apuntando siempre al comentario original, nunca a otra respuesta: eso es lo que mantiene el límite de "un solo nivel de anidamiento" real, en la base). reply_to_id es solo para agrupar en la pantalla — nunca arma un hilo más profundo.groupReplies (adentro de EngagementBlock.jsx) resuelve la cadena completa (una respuesta que le contesta a otra respuesta) hasta encontrar la raíz, y agrupa todo bajo ese mismo nivel extra de sangría — no se sigue escalonando más profundo por cada respuesta-a-una-respuesta.postReply guarda reply_to_id cuando llega (tocaste "Responder" en una respuesta puntual); queda null si tocaste el "Comentar" general. profile_activity/recent_activity ahora también devuelven reply_to_id dentro de cada objeto de replies, para que ActivityCard agrupe igual que ComentariosScreen.La pantalla de un club (/club/[clubId], con el héroe de portada) ya no muestra, debajo, la lista de los últimos comentarios/citas/notas de voz del libro ("Impresiones recientes") — quedaba redundante con Comentarios del club, que ya es adonde se entra a comentar de verdad.
ClubScreen.jsx, no un cambio de datos.Crear o unirme a un club es dos accesos que abren la misma hoja ("Sumar un club", src/components/AddClubSheet.jsx, con las opciones "Crear un club nuevo" / "Unirme con un link"):
Elegir "Unirme con un link" en la hoja manda a /club/nuevo?modo=unirme, que abre OnboardingScreen directo en la pestaña "Unirme a un club" (prop initialMode) en vez de en "Crear club" — mismo formulario de siempre, solo que ya parado en la pestaña que se buscaba.
RLS solo deja ver los clubes propios, y eso no se toca. Lo que otros clubes leen se expone por funciones security definer: other_clubs_reading_count y other_clubs_activity (migración 005) para "otros clubes leyendo el mismo libro", y discover_public_clubs (migración 007, extendida en la 014) para el directorio de /descubrir. Los clubes con invitación (antes "privados") aparecen anonimizados ("Un club") y no aparecen en el directorio; los abiertos y los "con solicitud" muestran su nombre y son unibles desde ahí, desde Preferencias del club (ícono de engranaje), donde cada opción explica exactamente qué ven los demás.
Postulaciones a clubes privados (migración 014). Cada club tiene un join_mode, tres valores en vez del viejo booleano is_private:
open) — listado en Descubrir, cualquiera se une con un toque.request) — listado en Descubrir con su nombre y un chip dorado, pero unirse requiere mandar una solicitud (con un mensaje opcional) que un administrador aprueba o rechaza. Igual que pedir seguir una cuenta privada de Instagram.invite) — el modo de antes: no aparece en Descubrir, solo se entra con el link de invitación.Las solicitudes viven en club_join_requests (pendiente/aprobada/rechazada), con RLS que separa dos roles: quien postula solo puede crear su propia solicitud o volver a postular si lo rechazaron, y el administrador del club es quien aprueba o rechaza. Aprobar es un segundo paso — una política aparte en club_members solo deja al administrador sumar a alguien que ya tenga una solicitud en estado approved; no puede sumar a cualquiera. Si te rechazan podés volver a postular en cualquier momento (no queda un estado "rechazada" visible, se comporta como si nunca hubieras postulado — igual que Instagram).
En Preferencias del club los administradores ven una sección "Solicitudes pendientes" con el mensaje de cada postulante y botones para aceptar/rechazar; el ícono de engranaje (en el club y en el header) muestra un número en rojo cuando hay solicitudes sin responder, para que no queden ignoradas en la cola.
Visibilidad al crear el club. El formulario de "Crear club" (Onboarding) tiene las mismas tres opciones (Público / Con solicitud / Privado, componente compartido VisibilityPicker en src/components/VisibilityOption.jsx) preseleccionadas en "Público". Antes no había ninguna elección acá — todo club nuevo se creaba privado por defecto (is_private default true en el esquema) sin que quien lo creara se enterara, así que quedaba invisible en Descubrir hasta que alguien entrara a Preferencias a cambiarlo a mano. Se puede seguir cambiando después desde Preferencias en cualquier momento.
Un usuario puede pertenecer a varios clubes (getMyClubs en src/lib/activeClub.js). No hay noción de "club activo": / (Mis clubes de lectura) muestra una tarjeta de héroe por cada club, con su propio progreso, y /club/[clubId]/comentarios/preferencias siempre leyeron el id de la URL — así que no hacía falta recordar en qué club estabas parado en ningún lado. (Hasta antes de esto existía una cookie, libris_club, para esa noción de club activo; se sacó entera al dejar de tener uso — ver "'Mis clubes de lectura' — una tarjeta de héroe por club" más abajo.)
Cada club tiene hasta 3 administradores, todos con las mismas facultades (no hay jerarquía entre ellos): quien crea el club es el primero, y desde Preferencias cualquier administrador puede nombrar hasta 2 más, o sacarle el rol a otro. La base impide que un club se quede sin ningún administrador (no deja sacar al último si todavía hay más miembros) y que se pase de 3.
Ser administrador da acceso a Gestionar capítulos (ícono de lista en el detalle del club, o desde Preferencias): ahí se define cuántos capítulos tiene el libro, se les puede poner nombre propio (se sigue mostrando el número: "Cap. 1. El inicio") y se pueden agrupar en volúmenes con el nombre que tenga sentido — "Libro 1"/"Libro 2", un año como "2026", lo que sea. Cada volumen decide su propia numeración: se puede seguir la secuencia anterior (10, 11, 12...) o arrancar de nuevo en 1, según cómo esté publicado el libro real. Los miembros que no son administradores ya no pueden agregar ni renombrar capítulos, solo elegir en cuál están al actualizar su progreso.
Además del hilo general del libro, ahora se puede comentar (texto, cita o nota de voz) un capítulo puntual — la pantalla de Comentarios tiene chips arriba para elegir "General del libro" o un capítulo específico, y solo muestra los comentarios de lo que está seleccionado.
Como no todos los miembros de un club leen la misma edición (cambia la paginación, no los capítulos), hay dos formas de registrar en qué vas — cada una en su propio lugar, ya no elegís entre las dos desde un mismo modal:
ChapterPath), sin abrir ningún modal. El % de la barra sale de en qué lugar de la lista de capítulos está ese capítulo (contando volúmenes en el orden en que fueron creados).UpdateProgressModal, que ya solo pide esto: página actual y total de páginas de tu propia edición. El % sale de esa proporción, y queda guardado por persona (current_page/total_pages en reading_progress, migración 011). El total de páginas solo se pide la primera vez: una vez que el modal ya sabe cuántas páginas tiene tu edición (initialTotalPages, lo último que guardaste), las siguientes veces solo pide en qué página vas — el total aparece como texto fijo ("de 320 páginas"), no como campo para volver a escribir. Un link chico ("¿Cambiaste de edición? Actualizar el total de páginas") lo vuelve a mostrar como campo editable, por si cambiaste de edición.UpdateProgressModal se achicó a solo esto — antes también dejaba elegir "Por capítulo" (una lista de chips redundante con tocar un nodo en Tu camino) y "Terminado" (redundante con el nodo de FIN al final del camino), más una reacción "¿Cómo estuvo?" (genial capítulo/capítulo lento) que no pintaba nada estando ya en modo página. Sacado todo: el único camino hasta este modal es "Actualizar por página", así que ya no hace falta preguntar qué querés hacer.
Aun así pregunta, aparte, en qué capítulo vas — "Para que también se vea en Tu camino, ¿nos cuentas en qué capítulo vas?". Es opcional ("Prefiero no decirlo" es la primera opción de la lista; no hace falta responder para guardar) porque actualizando por página no todo el mundo tiene el capítulo exacto en la cabeza. Si lo contestás, updateProgress guarda ese chapter_id junto con la página — el % sigue saliendo de la proporción de páginas, no se recalcula por capítulo — y así el nodo actual de "Tu camino" también queda bien ubicado, aunque hayas actualizado por página. Se probaron varias formas de hacer la pregunta (directa, explicando el porqué, más conversacional, bajando la exigencia con "aunque sea aproximado") antes de elegir esta, la que explica de entrada para qué sirve responder.
El capítulo se elige con un <select> nativo (groupChaptersByVolume, agrupado por volumen con <optgroup> si el libro tiene más de uno), no con chips: se probaron chips (con muchos capítulos se desordenan), chips cercanos al último capítulo + "ver todos", y un selector numérico tipo stepper, antes de elegir el select — escala a cualquier cantidad de capítulos sin armar ningún scroll propio, es el sistema operativo el que arma la lista (rueda en iOS, lista con scroll en Android).
El servidor es el que calcula el % siempre (antes lo elegía un slider que en realidad medía el avance dentro del capítulo, no el del libro entero — quedaba inconsistente con la barra).
El detalle del club (ClubScreen) mostró primero la portada a pantalla completa ("a sangre", ver design_handoff_hero_portada/ para ese diseño original), con un editor de encuadre aparte para que el admin eligiera qué parte de la foto se veía. Después fue un héroe grande de color sólido (mismo mecanismo visual que BookReviewCard, la tarjeta de "Reseña final": panel accent-500, kicker/título/autor arriba, portada flotando sin recortar, sombra apilada en diagonal y brillo por encima), primero en /club/[clubId] y después arriba de "Mis clubes de lectura". Ese héroe grande, como bloque fijo de una pantalla, ya no existe: se convirtió en la tarjeta de cada club en / (ver la sección siguiente), y /club/[clubId] pasó a arrancar directo en "Tu camino" (dos secciones más abajo) — la misma solución visual, solo que ahora vive en la lista en vez de arriba de una pantalla propia.
/club/[clubId]/portada, arrastrar y hacer zoom) se sacó entero, desde el principio de todo esto: la portada nunca se recorta, se muestra completa con su proporción natural. Con eso se fue cover_crop (deja de leerse y escribirse — la columna sigue en books, sin usar, no se borró) y src/lib/coverFrame.js quedó sin ese consumidor (lo sigue usando PhotoCropModal, que es un recorte genérico para fotos de lectura y de perfil — nada que ver con esto).src/lib/quoteCard.js, estilo "cover") sigue dibujando la portada a pantalla completa detrás de la cita, y ahí sí hace falta saber si esa portada ya trae el título impreso para no repetirlo. Ese switch (cover_has_title en books) vive en Preferencias del club → El libro en curso ("La portada ya trae el título"), guardado junto con el título/autor del libro en la misma Server Action (updateClubPreferences).En vez de una fila angosta por club, cada club en / es una tarjeta con el mecanismo visual del héroe: nombre del club arriba (fila propia, sin tocar), y debajo una fila con la portada a la izquierda (recortada a object-fit: cover, con la sombra apilada en diagonal) y a la derecha, en columna, el kicker "Leyendo ahora"/título/autor arriba, solos, y abajo del todo la barra de progreso — siempre continua, nunca pips — con el % pegado al final del relleno (no como número aparte) y la meta de progreso (página o capítulo) como pie de foto debajo de la barra. Todo alineado con el borde inferior de la portada — la barra ya no es una franja aparte que cruza toda la tarjeta, arranca donde termina la portada. Si un club todavía no tiene libro activo, su tarjeta muestra solo el nombre y "Todavía no tiene un libro activo".
ClubHeroCard (componente local de src/screens/MisClubesScreen.jsx) — una tarjeta por cada club del usuario, no solo la de "el club activo" (esa noción ya no existe en esta pantalla): cada una trae su propio progreso, con getClubProgressSummary (src/lib/clubDetail.js) — una versión liviana de la consulta que usa /club/[clubId], solo capítulos/volúmenes/mi progreso, sin reseña, actividad ni solicitudes pendientes, que acá no se muestran.
Tocar una tarjeta entra directo a /club/[clubId], que ahora arranca en "Tu camino" — no hace falta pasar primero por un héroe grande, ya se vio en la tarjeta.
Se sacó el adelanto de Descubrir que vivía debajo de la lista de clubes: quedaba raro debajo de las tarjetas grandes, y Descubrir ya tiene su propia pestaña, así que repetirlo acá no sumaba.
La cookie de "club activo" se sacó entera — ACTIVE_CLUB_COOKIE, getActiveClub y la Server Action selectClub ya no existen (src/lib/activeClub.js quedó solo con getMyClubs/getActiveClubBook). No le quedaba ningún lector: /club/[clubId]/comentarios y /club/[clubId]/preferencias siempre leyeron el id de la URL, no la cookie, así que perdió su único propósito al dejar de existir un héroe único que mostrar en /.
Los integrantes del club, debajo de la barra de progreso — la pila de avatares de siempre (hasta 3, con "+N" si sobran, mismo criterio de color que Avatar.jsx) más "Fulano y N más", en una fila propia debajo de la barra (se probó también una versión compacta arriba, junto al nombre del club, pero esta se veía más — la tarjeta ya crece un poco pero el dato vale la pena). Tocarla no entra al club: abre ClubMembersModal (src/components/ClubMembersModal.jsx), la lista completa de integrantes — cada uno con su avatar, su nombre (con "(tú)" si sos vos) como link a su perfil, y el botón para seguirlo si no sos vos. Como la tarjeta entera ya es un link al club, la fila de avatares es un <button> que llama preventDefault+stopPropagation antes de abrir la lista, para que el toque no dispare también la navegación de la tarjeta.
FollowButton (src/components/FollowButton.jsx) — se sacó de PerfilScreen.jsx (donde vivía como función local) para poder usarse acá también; mismo comportamiento de siempre (/, y "Dejar de seguir" pide confirmación en una hoja aparte, no en un solo toque).No hizo falta ninguna migración — los datos y RLS ya alcanzaban (incluida la tabla follows, que ya existía para seguir gente desde Perfil).
/club/[clubId] — directo a "Tu camino"Al tocar una tarjeta de "Mis clubes de lectura", esta pantalla ya no muestra un héroe grande arriba (ya se vio como tarjeta): empieza con un encabezado liviano — flecha para volver + nombre del club a la izquierda, y a la derecha Invitar/Compartir club y Preferencias (con su punto de solicitudes pendientes) — y debajo, directo, "Tu camino" y "Actividad del club" (sección siguiente), sin nada en el medio.
El selector de club (ClubSwitcher, el menú que dejaba saltar de un club a otro desde arriba del héroe) se sacó entero — ya no hace falta: "Mis clubes de lectura" es ahora el lugar rápido y visual para elegir con qué club seguir, así que volver (flecha atrás) y tocar otra tarjeta cumple el mismo rol.
ChapterPath (src/components/ChapterPath.jsx, reemplaza a ChapterProgressChips) — un camino vertical con el libro completo, de punta a punta: Cap. 1 arriba, el último abajo, en orden, como una tabla de contenidos (se probó primero al revés, estilo Duolingo, con lo que faltaba arriba y una ventana recortada de solo 2 capítulos atrás/3 adelante del actual — pero tapaba la ruta entera y se sentía menos natural que verla toda de corrido). Tocar un capítulo lo marca como propio al toque (misma Server Action updateProgress, mode: 'chapter', sin abrir modal) — mismo patrón optimista + aviso breve que ya tenían los chips. Al abrir la pantalla, el camino se centra solo en tu capítulo actual (scrollIntoView, una vez al montar) — así no hay que buscarlo a mano; si después tocás otro capítulo, no te saca del lugar donde acabás de tocar. En la etiqueta del capítulo, el nombre propio (si tiene) va siempre abajo del número — antes eso faltaba en tu capítulo actual (mostraba "Cap. 4 / TU CAPÍTULO" sin el nombre, aunque el capítulo lo tuviera). Como ya no hay "+N capítulos más" para abrir el modal completo (progreso por página, reacciones, "Terminé el libro"), un link chico ("Actualizar por página") al lado de "Tu camino" cumple ese rol.pathColor (en ChapterPath.jsx) interpola entre un gris azulado frío (#3B4B66, el capítulo más viejo) y --accent-500 (siempre exacto en tu capítulo actual) según qué tan lejos está cada capítulo del actual — se probaron varias parejas de color (varios grises hacia un verde, mostaza hacia verde, rosado hacia verde) antes de volver al coral de siempre como punto de llegada. Lo que falta sigue en gris plano, sin degradé — el degradé es solo del camino recorrido.ChapterPath recibe volumes además de chapters) — el mismo nombre real que le pusiste en "Gestionar capítulos" ("Libro 1", "Libro 2"...), la primera vez que aparece un capítulo suyo. Primera versión de esto llevaba un carril de color corrido al lado de todo el camino, más una pastilla de color junto al título — se sacó entero: quedaba mejor solo el título, centrado, sin ninguna línea seguida al lado. Con un solo volumen (o ninguno, el caso de siempre hasta ahora) no se muestra nada — el camino queda exactamente igual que antes.Comentar, grabar o adjuntar directo desde Tu camino, sin abrir Comentarios. Cada nodo tiene su propio broche "+", en la esquina inferior derecha (mismo lugar que ya usa la insignia de racha para la esquina opuesta) — se probaron antes, como artifact aparte con los tokens reales de la app, varias ubicaciones (un botón punteado junto a la pastilla de comentarios, una sola pastilla combinada, un botón único al pie del camino) y varios gestos (deslizar la etiqueta, tocar la etiqueta, mantener presionado el nodo con un anillo de progreso) — se eligió el broche sobre el nodo: queda siempre a la vista sin sumar un objeto nuevo a la fila, y —a diferencia de los gestos— es un click común, sin temporización ni umbral que afinar, así que funciona igual en el celular y en el navegador de escritorio. El nodo, antes un <button> solo, pasó a ser un <div> contenedor con el círculo y los dos broches (racha y "+") como hijos independientes — necesario para que tocar el "+" no dispare también el marcado de progreso. Tocarlo de nuevo (o la "x" en la que se convierte mientras está abierto) lo cierra. Marcar un capítulo como leído ya no abre nada solo — el broche es la única puerta a agregar algo.
NewCommentForm gana kind/hideKindChips/autoOpenPicker — cuando alguien de afuera ya eligió el tipo (las pestañas del panel), kind fija el tipo desde el mount y hideKindChips esconde su chip interno (que sigue ahí, sin cambios, para cuando el formulario elige su propio tipo — la pantalla de Comentarios del club lo sigue usando así). autoOpenPicker abre el selector nativo de archivos apenas se monta: la pestaña "Foto/GIF" lo usa para ir directo a elegir, un clic de menos que en "Comentario" (que también puede llevar fotos, solo que no arranca ahí). Comentario, Cita y Foto/GIF son la misma forma por dentro con distinta configuración — ChapterCommentsPanel remonta una instancia nueva por pestaña (key={activeTab}), así que cambiar de tipo nunca arrastra texto o fotos de la pestaña anterior. Voz sigue siendo VoiceRecorder, aparte — no comparte formulario con las otras tres.revalidatePath que ya hacían postComment/postVoiceComment — no hizo falta ningún mecanismo nuevo para refrescarla.NewCommentForm ya no recibe clubName/personName (no los usaba para nada) y ComentariosScreen dejó de pedir y pasar myDisplayName — ninguno de los dos se usaba ya en ningún lado.Las mismas 4 pestañas en la barra del Perfil, con notas de voz nuevas ahí (migración 052). El mismo panel de tipo (Comentario · Cita · Foto/GIF · Voz) que ya se armó para Tu camino se llevó a la barra "¿Qué estás leyendo?" del perfil, reemplazando el AttachmentToolbar de íconos sueltos — mismo componente TypeTabButton, mismo estilo. La pestaña "Voz" era nueva de verdad ahí: posts no tenía ninguna columna para guardar audio.
posts gana voice_url/voice_transcript/voice_duration_seconds — nullable, sin default, mismo criterio de siempre: un post ya publicado no tiene ninguna, sigue sin mostrar nada de esto. posts_content_check se actualiza para aceptar voice_url como contenido válido, junto a image_url/caption (antes de esta migración, solo esas dos).post-voice-notes — PÚBLICO, a diferencia de voice-notes (el de club, privado, con URLs firmadas). Un post ya es visible para cualquier usuario autenticado (la policy de select de posts es using (true) desde la migración 016) — mismo criterio que ya usa post-photos: se guarda la URL pública directa, no un path a firmar.VoiceRecorder se vuelve genérico — antes llamaba siempre a postVoiceComment con clubBookId/chapterId fijos; ahora recibe postAction (qué Server Action llamar) y extraFields (qué sumarle al FormData además de audio/duración/transcripción — { clubBookId, chapterId } para una nota de capítulo, nada para un post) — los dos usos que ya existían (ComentariosScreen, el panel de Tu camino) pasaron a extraFields, sin cambiar de comportamiento. showSpoilerOption esconde el checkbox de spoiler donde no aplica — los posts no tienen ese concepto.QuoteComposer gana embedded — sin su propio <Modal>, para vivir como el contenido de la pestaña "Cita" dentro del mismo panel de "Compartir", en vez de abrir una segunda ventana encima de la primera. También esconde su botón "Cancelar" en ese modo (cerrar es trabajo de la × del panel que lo contiene).ActivityCard suma un VoiceNotePlayer de verdad para este caso, con su duración formateada y la transcripción como "Ver transcripción" colapsable (mismo componente que ya usa Comentarios, antes nunca instanciado desde el feed). profile_activity/recent_activity se redefinen para devolver voice_url/voice_duration_seconds (nuevas en las dos) y (ya existía en , se suma a , que hasta acá no la traía para nada — una nota de voz de club compartida al feed general solo decía "Publicó una nota de voz.", nunca mostraba la transcripción real aunque hubiera una).Más adelante: "Comentarios de tu camino" también adopta la misma barra que "Compartir" del Perfil, ahora fija al pie. Entrando desde un capítulo puntual (pastilla de Tu camino), el pie de la pantalla mostraba siempre un NewCommentForm + un VoiceRecorder, los dos desplegados a la vez, sin pestañas — quedaba desalineado con las otras tres puertas de entrada (el broche "+" de Tu camino, "Compartir" en Perfil), que ya usaban el mismo panel de 4 pestañas (Comentario/Cita/Foto·GIF/Voz) hacía rato.
ChapterComposerTabs (src/components/ChapterComposerTabs.jsx, nuevo) — se extrajeron de ChapterCommentsPanel (ChapterPath.jsx) las cuatro pestañas de siempre (más una quinta, "Pregunta", si isAdmin) y AdminQuestionTab, como componente compartido — ChapterCommentsPanel quedó reducido a un Modal con este componente adentro. Ahora lo usan DOS lugares: el broche "+" de Tu camino (con isAdmin) y la nueva barra fija de Comentarios (sin esa pestaña — armar la encuesta del capítulo sigue siendo cosa del broche "+" o de Gestión de capítulos, no de acá).ChapterComposerBar (ComentariosScreen.jsx, nuevo) — la misma pastilla que PostComposer en Perfil (avatar + "¿Qué te pareció este capítulo?"), que al tocarla abre el mismo Modal con ChapterComposerTabs adentro. Reemplaza al viejo par NewCommentForm+VoiceRecorder siempre desplegados, SOLO para la entrada por capítulo puntual (cameFromChapterLink) — el modo "entrada general" (sin capítulo, hoy sin ningún link que lo dispare) conserva el formulario de siempre, sin tocar.ChapterComposerBar va en un contenedor position: sticky con bottom: calc(70px + env(safe-area-inset-bottom, 8px)) (el mismo alto que ya reserva AppShell para su tab bar, así queda pegada justo arriba, nunca tapada por él) — se queda a la vista mientras se scrollea el hilo de comentarios de arriba, como una barra de escribir de chat.PostComposer ya recibía el perfil completo de quien mira (Perfil, propio), pero comentarios/page.js nunca había necesitado el PROPIO perfil (solo user.id, para comparar profile_id). Se sumó una consulta más al Promise.all de siempre (profiles.select('display_name, avatar_url').eq('id', user.id).single()), pasada como myProfile.Bug: la app cargaba "rota" — sin estilos, sin fuentes, sin imágenes. El service worker (public/sw.js, existe solo para que Chrome/Android la considere instalable como PWA) interceptaba TODOS los pedidos y usaba una estrategia "red primero, caché como respaldo" — guardando en caché cada respuesta exitosa. El problema: esta app despliega seguido, y cada despliegue genera nombres nuevos (con hash) para los archivos CSS/JS — un archivo recién generado nunca estuvo en caché. Con una conexión débil que fallaba justo al pedir uno de esos archivos nuevos, no había a qué "caer": la petición fallaba sin más, y la pantalla quedaba con el HTML pelado, sin nada de estilo (exactamente lo reportado: texto sin formato, botones como links subrayados, portadas sin cargar).
fetch queda vacío (sin respondWith, cada pedido pasa directo a la red, el comportamiento por default del navegador), que ya alcanza para que Chrome siga considerando la app instalable — no hace falta que el manejador haga nada más que existir.ƒ dinámica — no hay nada para "leer offline"), así que el beneficio que daba cachear el app shell era mínimo frente al riesgo real que introducía.activate ahora borra cualquier caché que haya quedado de la versión anterior (se llamaba libris-shell-v1), para no dejar nada viejo dando vueltas en los navegadores que ya tenían el service worker instalado.Preguntas de capítulo (migraciones 053 y 054): encuesta, pregunta abierta o trivia que arma el administrador, atada a un capítulo puntual — y puede haber varias por capítulo. Saltan una por una, como hoja desde abajo (mismo Modal de siempre), apenas alguien marca ESE capítulo como el que está leyendo — mismo momento que ya dispara el toast "Listo, vas por el Cap. N", en ChapterPath. Todo el club ve el resultado agregado una vez que responde. Se armó primero como maqueta (artifact) con las cinco pantallas — admin armando la pregunta, encuesta sin responder, encuesta respondida, trivia respondida y pregunta abierta respondida — antes de escribir código real.
chapter_questions (la pregunta: tipo, enunciado, opciones si aplica, capítulo al que está atada) y chapter_question_answers (una fila por persona que respondió). Nadie edita su propia respuesta una vez enviada.poll (opciones fijas, sin correcta), open (texto libre) y trivia (opciones fijas, una marcada correcta) — un check en la tabla obliga a que cada tipo lleve exactamente lo que le corresponde (options/correct_option_index nulos o no, según el tipo).comment_photos (migración 042): cualquier miembro del club lee la pregunta y TODAS las respuestas (hace falta para armar el % agregado); solo un administrador arma/edita/borra la pregunta (public.is_club_member/chequeo de role = 'admin', mismo patrón que ya usan promoteAdmin/demoteAdmin); cada quien inserta únicamente su propia respuesta, una sola vez (unique(question_id, profile_id)).chapter_id como unique en chapter_questions: un admin no podía tener, por ejemplo, una encuesta Y una trivia en el mismo capítulo, armar una nueva reemplazaba la que había. La migración 054 saca ese unique (deja un índice común en su lugar) y saveChapterQuestion/getChapterQuestions (antes singular, getChapterQuestion) pasan a trabajar con listas en vez de una fila única.ChapterQuestionsManager (GestionCapitulosScreen.jsx, exportado, antes ChapterQuestionEditor) — administra la LISTA de preguntas de un capítulo: cada una ya guardada se ve como QuestionSummaryCard (enunciado, opciones — la correcta resaltada en trivia — y un tag "✓ Guardada"), con "Editar" y "Borrar" propios; al pie, "Agregar pregunta" (si no hay ninguna todavía) o "Agregar otra pregunta" abre QuestionForm en blanco. QuestionForm es el formulario en sí, extraído aparte para poder abrirse tanto en blanco como precargado: elige el tipo con tres chips (mismo lenguaje visual que las pestañas de tipo de otros paneles), escribe el enunciado, y en poll/trivia una lista de opciones (agregar/quitar, hasta 6) con un círculo para marcar la correcta en trivia. saveChapterQuestion (clubs.js) inserta una fila nueva o actualiza una existente según si QuestionForm recibió un questionId (edición) o no (alta); borra una pregunta puntual por su (y en cascada, sus respuestas). Vive en dos lugares:
"Quiénes están leyendo" vuelve, opt-in. Se había sacado del todo (ver "Se sacó 'Quiénes están leyendo' entera", más abajo) por quedar redundante frente a los integrantes del club en "Mis clubes de lectura" — pero esa lista no dice EN QUÉ CAPÍTULO va cada uno, que es lo que realmente se había pedido de vuelta. Vuelve solo a Tu camino, y como algo que se elige ver o no — un botón "Ver compañeros" junto al subtítulo, que al tocarlo (recién ahí, no en cada carga de la pantalla) trae el capítulo actual de cada compañero y los muestra como un stack de avatares chicos debajo del nodo donde va cada uno (hasta 4 superpuestos, el resto en un "+N"; el nombre de todos queda en el title nativo, al pasar el mouse).
getClubMembersProgress (src/app/actions/clubs.js) — trae club_members del club (excluyendo al propio usuario) cruzado con reading_progress de ese libro; a quien todavía no registró ningún progreso no hay dónde ubicarlo, así que se lo deja afuera. Se apoya solo en las policies de RLS de siempre (cualquier miembro del club ya puede leer club_members/reading_progress/profiles de su propio club) — no hizo falta ninguna función security definer nueva, a diferencia de otros casos donde el RLS real resultó más angosto que la visibilidad esperada (reposts, fotos de comentarios).Este cambio dejó expuesto un bug de layout preexistente en AppShell: el tab bar de abajo es position: sticky, y cuando el contenido de una pantalla mide apenas un poco más que la pantalla del teléfono, "sticky" no empuja lo de arriba — lo tapa. Se corrigió reservándole su alto real como padding-bottom del contenido (calc(70px + env(safe-area-inset-bottom, 8px))), así el tab bar nunca vuelve a superponerse al final de ninguna pantalla.
Se repasaron los ~20 textos de toda esta zona (Tu camino, el modal de página, la tarjeta de club) de a uno, en un ida y vuelta directo, en vez de reescribir todo de una — algunos quedaron igual, otros cambiaron. Cambios que quedaron:
heroProgress.js — cuando el libro ya tiene capítulos pero todavía no marcaste ningún progreso, el texto bajo la barra decía "N capítulos" (describía el LIBRO, no tu progreso — al lado del 0% no calzaba). Pasa a "Todavía no empezaste".De paso, un hueco real en la lógica de spoilers (no solo texto) — surgió al discutir el #2: la advertencia de spoiler (SpoilerWarning, en la pastilla de comentarios de cada capítulo) solo se disparaba para capítulos posteriores al actual (isAhead: originalIndex > currentIndex). Estar "en" un capítulo no significa haberlo terminado — vas por la mitad, o recién arrancaste — así que sus propios comentarios ya podían adelantar algo que todavía no leíste de ESE MISMO capítulo, sin ningún aviso. isAhead pasa a >= (incluye tu capítulo actual); solo los capítulos YA marcados como leídos (isDone) siguen siendo un link directo. El texto de la advertencia, que asumía "de acá para allá" (solo capítulos futuros), se reescribió para cubrir también el actual: "Todavía no marcaste {capítulo} como leído — puede que encuentres spoilers ahí."
Después de esa revisión, un barrido del resto de la app buscando voseo suelto — la convención de siempre es tuteo (español latino neutro), y esta zona de progreso se había colado con "Tocá". Un agente exploró los ~118 archivos de src/ buscando marcas de voseo en texto de usuario real (no en comentarios de código) y encontró 8 más, fuera de "Tu camino":
postComment/error de reunión (clubs.js): "Poné la dirección..." / "Poné el link..." → "Pon la dirección..." / "Pon el link..."NotificationsButton.jsx): "Todavía no tenés notificaciones." → "...no tienes notificaciones."NewCommentForm.jsx): "Probá de nuevo." → "Prueba de nuevo."PostComposer.jsx y EditPostModal.jsx, mismo texto en los dos): "Escribí algo sobre esta foto (opcional)" → "Escribe algo..."Después, un barrido de claridad más amplio (no solo dialecto) — un agente exploró toda la app buscando textos genuinamente confusos (no solo de estilo): pronombres sin referente claro, el mismo concepto llamado de dos formas distintas en pantallas diferentes, jerga técnica filtrándose, botones cuya acción no queda clara. El más grave, con arreglo real (no solo de texto):
friendlyDbError — en login/registro (src/app/login/actions.js), al crear un club (createClub/findOrCreateBook, clubs.js), y en cada subida a Storage que fallara (portada, avatar, nota de voz, foto de Perfil — media.js/posts.js). El motivo: friendlyDbError solo traducía errores de RLS ("no tienes permiso...") — cualquier otra cosa (una restricción única violada, un límite de tamaño de Storage, un problema de conexión) se devolvía tal cual, muchas veces en inglés, justo en el flujo de login/registro, el más transitado de toda la app.
friendlyDbError (src/lib/friendlyError.js) ahora reconoce los 3 mensajes que sí armamos a mano en los triggers de Postgres (KNOWN_DB_MESSAGES — ya están en español, se dejan pasar tal cual), reconoce un par de errores comunes de Storage (archivo demasiado pesado, tipo no soportado / bucket mal configurado), y para cualquier otra cosa no reconocida devuelve un mensaje genérico en español en vez del string crudo del driver.friendlyAuthError — mismo espíritu, para errores de Supabase Auth (contraseña incorrecta, email sin confirmar, cuenta ya registrada, límite de intentos), que no tienen nada que ver con RLS y antes tampoco se traducían.return { error: ... } sueltos de createClub/findOrCreateBook, los 4 uploadError.message sueltos de media.js/posts.js, y los 3 error.message sueltos de login/actions.js (ahora friendlyAuthError).El resto de los hallazgos de ese mismo barrido, ya resueltos:
ComentariosScreen.jsx para el mismo caso).join_mode: 'open') — unificado a "Abierto": describe mejor lo que distingue a esta opción de "Con solicitud" (nadie pide permiso), ya que las dos aparecen igual de visibles en la búsqueda.InicioScreen/MisClubesScreen/OtrosClubesScreen), que apunta a la ruta /descubrir sin mostrar ese nombre en ningún lado. Las dos menciones en VisibilityOption.jsx pasan a "la búsqueda de clubes" / "la búsqueda".OtrosClubesScreen, InviteScreen, AddClubSheet) — pasa a "Entrar a la videollamada".Debajo del encabezado, la pantalla del club sigue con estas piezas (completando la dirección de diseño "Centro del club" del handoff):
SwipeableSections (src/components/SwipeableSections.jsx) — "Tu camino" y "Actividad del club" viven en un carrusel horizontal en vez de apiladas: arranca en "Tu camino", deslizar a la derecha lleva a "Actividad del club". Scroll nativo con scroll-snap (el gesto de deslizar de toda la vida, sin librería) más unos puntitos abajo del encabezado — como los de Instagram Stories — que muestran en cuál estás y se pueden tocar para saltar directo. Componente genérico (recibe sections: [{key, node}]), hoy solo lo usa esta pantalla.ClubActivityFeed (src/components/ClubActivityFeed.jsx) — tarjetas con lo último que pasó: comentarios recientes agrupados por capítulo ("Bruno y 2 más comentaron el Capítulo 4") y reseñas finales, cada una su propia tarjeta ("Sofía terminó el libro y dejó su reseña"). El agrupamiento vive en src/lib/clubActivity.js: junta por chapter_id los últimos comentarios (sin respuestas, sin reseñas) y arma reseñas aparte, ordenado todo por lo más reciente. Es una ventana de "lo más reciente" (últimos 24 comentarios / 8 reseñas), no un historial completo — con actividad muy espaciada en el tiempo puede juntar en una misma tarjeta comentarios de hace días si fueron los últimos en ese capítulo.Ninguna de estas piezas necesitó migración: los datos y los permisos ya existían, solo faltaba consultarlos y mostrarlos.
"Quiénes están leyendo" se sacó entera, de las dos pantallas donde vivía: la franja de avatares arriba del carrusel (MemberProgressStrip, componente eliminado) y el botón "Mostrar quién está leyendo" dentro de "Tu camino" (compañeros por capítulo, en ChapterPath). Quedó redundante frente a los integrantes del club que ahora se ven en "Mis clubes de lectura" (ver más arriba) — esa lista ya cumple el mismo rol (quién está en el club) y de paso deja seguir a cualquiera. Se sacó también toda la plomería que solo alimentaba esto: la consulta cruzada club_members+reading_progress en /club/[clubId]/page.js, y las props members/currentUserId de ChapterPath y ClubScreen (ChapterPath ya no necesita saber quién sos para dibujar el camino).
Arriba a la derecha de "Inicio", al lado del logo — mismo lugar que el corazón de Instagram, pero con un libro (Icon name="book-open") en vez de un corazón: acá "me gusta" no es el ícono de marca de la app. Un punto rojo (--accent-500) avisa que hay algo nuevo; tocar el ícono abre una hoja con la lista completa y apaga el punto de una sola vez al abrir — no hay "marcar como leída" notificación por notificación, mismo criterio que usa Instagram con el suyo.
notifications_feed (función security definer, como recent_activity) arma la lista al vuelo cruzando lo que ya existía: alguien te empezó a seguir (follows), le pusieron "me gusta" a un comentario/cita/reseña o a una foto tuya (comment_likes/post_likes), te respondieron (comments.parent_comment_id), o comentaron una foto tuya (post_comments). Todo filtrado por auth.uid() del lado del servidor, igual que el resto de las funciones de la app — nunca trae nada que no sea tuyo.profiles.notifications_seen_at — no una fila por notificación con su propio estado de leído. getNotifications (src/lib/notifications.js) compara la fecha de cada evento contra ese timestamp para pintar el punto de "nuevo" en la lista y decidir si mostrar el punto rojo del ícono. Abrir la hoja llama a la Server Action markNotificationsSeen (src/app/actions/profile.js), que pone ese timestamp en now() — optimista del lado del cliente (el punto se apaga al toque, no espera la respuesta del servidor).describe() en notifications.js): un comentario/respuesta lleva al capítulo puntual del club (?capitulo=<id> si lo tiene), un nuevo seguidor lleva a su perfil, y un "me gusta"/comentario en una foto lleva a tu propio Perfil (/perfil) — no hay otro lugar donde ver tus fotos.NotificationsButton (src/components/NotificationsButton.jsx) — la campana y la hoja (Modal), auto-contenidos: no le hace falta al resto de "Inicio" saber que existen.No hizo falta ninguna política de RLS nueva — todas las tablas que notifications_feed cruza ya tenían las suyas, y la función filtra por auth.uid() del lado del servidor como cualquier otra security definer de la app; notifications_seen_at se actualiza con la misma política de "cada quien edita su propio perfil" que ya usaba updateProfile.
Los administradores pueden cargar, en Preferencias, cuándo es la próxima reunión del club y dónde — con dos modalidades para el "dónde": un link (Zoom, Meet, lo que sea) o un lugar físico (dirección o nombre de local). Si hay fecha cargada, todo el club la ve arriba de todo en la pantalla del club (/club/[clubId]), con un botón que abre el link tal cual si es por videollamada, o un link a Google Maps armado con el texto del lugar si es presencial — sin mapa embebido ni API key: googleMapsUrl() (src/lib/meetingFormat.js) solo arma https://www.google.com/maps/search/?api=1&query=<lugar>, la búsqueda pública de Maps. Guardar coordenadas o pagar por un mapa interactivo hubiera sido pasarse de lo que hacía falta — un link que abre Maps con el lugar ya escrito resuelve "cómo llego" igual de bien.
clubs: meeting_at (timestamp sin huso horario, a propósito — mismo criterio que ya usa el resto de la app para fechas, ver formatRelativeTime.js: lo que la persona administradora escribe en el datetime-local se guarda y se muestra tal cual, sin convertir entre husos), meeting_mode ('link' o 'lugar'), meeting_link, meeting_place. Sin política de RLS nueva — la de "los administradores editan su club" (migración 009) ya cubre cualquier columna de la fila, y "members read their clubs" ya deja leer la fila entera a cualquier integrante.updateClubPreferences (src/app/actions/clubs.js) pone las 4 columnas en null — no queda un link o un lugar "colgado" sin fecha.MeetingModeOption (componente local de PreferenciasScreen.jsx) — el mismo patrón visual de chip que el resto de la app, pero con un <input type="radio"> nativo debajo (oculto), para que viaje en el FormData del formulario sin JS extra al enviar — igual que ya hace VisibilityOption para elegir la visibilidad del club.ClubScreen.jsx) es una tarjeta chica entre el encabezado y "Tu camino", solo si club.meeting_at existe — no compite con el resto de la pantalla si nadie cargó una reunión.googleMapsUrl(). searchPlaces (Server Action, src/app/actions/places.js) hace el pedido del lado del servidor (Nominatim exige un User-Agent propio, que no tendría sentido mandar desde el navegador de cada persona) y solo responde a partir de 3 letras. PlaceAutocompleteInput (src/components/PlaceAutocompleteInput.jsx) es un input de texto normal — viaja en el FormData como cualquier otro campo, name="meetingPlace" — con el pedido debounceado 400ms para no perseguir cada tecla (Nominatim pide como máximo ~1 solicitud por segundo; con el debounce, el uso normal de la app queda lejos de ese límite). Elegir una sugerencia solo completa el texto del campo — no se guardan coordenadas, sigue siendo la misma dirección en texto libre que ya usa . Lleva el crédito a OpenStreetMap debajo de la lista, como pide su política de uso.Subir la portada (CoverUploader, en "Preferencias del club" → "El libro en curso") ya no sube la foto tal cual, achicada — antes de subirla se abre un recorte propio. Antes ninguna pantalla forzaba una proporción — cada tarjeta simplemente encuadraba "cover" lo que hubiera, así que una foto vertical, horizontal o cuadrada se recortaba distinto en cada lugar donde se mostraba, sin que quien subió la foto pudiera elegir qué parte quedaba dentro. Con el recorte, la persona decide de una vez qué parte de su foto es la portada, y esa decisión es la que se sube.
La primera versión usaba una proporción fija (2:3, la de un libro de bolsillo), reusando PhotoCropModal — el mismo recorte estilo Instagram (arrastrar + acercar) de la foto de perfil y las fotos de publicación, solo con su propio aspect. Se cambió enseguida: hay libros bien cuadrados, y con una proporción fija esos quedaban recortados igual que uno vertical, sin remedio.
CoverCropModal (src/components/CoverCropModal.jsx) es el reemplazo, un componente aparte — no una rama más de PhotoCropModal, porque la interacción es otra. Ahí la foto queda quieta, mostrada entera, y lo que se ajusta es el recuadro de recorte: se arrastran sus esquinas para darle cualquier forma (vertical, cuadrada, lo que haga falta) y el interior para moverlo, sin proporción fija. Al confirmar, el recuadro se recorta de la foto original (canvas.drawImage) escalado hasta un máximo de 1200px en su lado más largo, y ese resultado es lo que sube uploadBookCover. PhotoCropModal y coverFrame.js siguen intactos y solo los usan AvatarUploader y PostComposer — la portada tiene ahora su propio mecanismo, pensado para su propio problema (proporción variable, no fija).
Con el recorte ya resuelto por la persona, CoverUploader dejó de llamar a compressImage (que solo achicaba sin recortar) — el recuadro elegido ya define el tamaño final de la imagen que se sube.
Se sacó el switch "La portada ya trae el título" de Preferencias (book.cover_has_title). Solo afectaba a la tarjeta de cita con estilo "Portada" (renderCover en quoteCard.js): si estaba activado (el default), no repetía el título encima de la imagen de fondo; desactivado, lo dibujaba. En la práctica era una utilidad muy angosta — el default ya acertaba en casi todos los casos (la portada real trae el título impreso) y el único momento en que el toggle importaba era con una portada recortada o "limpia" sin texto, un caso raro para justificar un control en la pantalla de Preferencias. Se sacó el checkbox, su estado y el campo del FormData/la acción del servidor; quoteCard.js quedó con el comportamiento que ya era el default (nunca vuelve a dibujar el título sobre la portada) en vez de leerlo de la base. La columna cover_has_title sigue existiendo en books — no se tocó ninguna migración, solo dejó de leerse y escribirse desde la app.
Se pidió acercar el look de ActivityCard (la tarjeta que arma el feed de Inicio y la lista de Perfil) a X/Twitter — sin recrear su interfaz de marca (sus íconos puntuales, las pestañas "Para ti"/"Siguiendo", el check de verificación): eso queda fuera. Lo que sí se trajo, dentro de la identidad de Libris (coral, crema, serif, sus propios íconos), fue la idea de fondo: un timeline más denso, con la fila de "me gusta"/"comentar" como íconos con número al lado en vez de píldoras de botón. Se probaron 3 mockups (timeline sin tarjetas, tarjeta compacta con borde fino, o solo cambiar la fila de acciones) y se eligió el primero.
padding: '12px 0' y un borderBottom fino (var(--border-subtle)) en vez de vivir dentro de una tarjeta con aire alrededor; InicioScreen/PerfilScreen sacaron el gap entre tarjetas de su lista (ya no hace falta, las separa la línea) y le sumaron un borderTop para que la primera publicación también tenga su línea arriba.metaText, título/autor, la cita o el comentario, tocable para desplegarlo) se mueve arriba de la imagen. El botón de descargar la cita como imagen y el link "Ver el resto de los comentarios" pasan a vivir después de la fila de acciones, no adentro del bloque de texto — quedan como un cierre de la tarjeta expandida, no antes de la imagen.metaText, debajo de la imagen. En Inicio (donde sí hay author) la hora ahora sale del encabezado; metaText la repite solo cuando no hay encabezado con nombre (Perfil, donde por diseño no se repite de quién es cada tarjeta) — para no mostrarla dos veces en Inicio ni perderla en Perfil.compact — la fila de acciones sin píldoras — nuevo prop opcional en LikeButton, EngagementBlock y PhotoCommentsBlock: ícono + número, sin fondo ni relleno, en vez del botón con fondo --accent-50/--surface-sunken de siempre. Es opt-in y por default sigue siendo la píldora de toda la vida — ActivityCard es el único que pasa compact; ComentariosScreen (los comentarios del club) y las respuestas dentro de un hilo (ReplyRow, que ya usaba un LikeButton chico) no lo tocan, quedan exactamente como estaban. Sin este prop hubiera hecho falta bifurcar los tres componentes o duplicar su lógica de "me gusta"/responder — con un prop de presentación alcanza, el comportamiento (like optimista, abrir/cerrar el campo de responder, el hilo agrupado) es el mismo código de siempre.padding: '20px 18px 24px' que ya traía cada pantalla: sacar ese padding solo para la lista de actividad hubiera significado reestructurar el resto de Perfil (info de perfil, formulario de editar, estadísticas), que no era parte de este pedido.Después, dos ajustes más sobre esta misma tarjeta:
Modal) con el hilo completo y el campo para escribir, siempre visible ahí abajo — la tarjeta en sí nunca crece. Adentro de esa hoja, cada comentario raíz tiene su propio "Ver N respuestas" independiente (ThreadGroup, en EngagementBlock) — colapsado por default, no uno solo para toda la lista. Fuera de compact (ComentariosScreen, los comentarios del club) el hilo se sigue mostrando siempre en el lugar de siempre, sin hoja aparte — mismo criterio que ya regía para la píldora vs. el ícono+número.BookReviewCard para la reseña (que no se tocó, ya cumplía esto). "más" solo aparece si el texto de verdad se corta: un useEffect compara scrollHeight contra clientHeight del bloque de texto después de pintar y solo entonces lo muestra — sin esa medición, cualquier cita cortita hubiera quedado con un "más" sin nada más para mostrar. Tocar el bloque entero lo sigue desplegando igual que antes; "más" es solo la pista visible de que hay más para leer, no reemplaza ese toque.metaText — la línea chica ("Compartió una foto", "Publicó una cita", o el nombre del club) que iba arriba del título/cita/comentario. Sobraba: con el nombre y la hora ya en el encabezado (ver más arriba), esa línea repetía la misma idea de otra forma. Se fue entera, junto con los íconos de cámara/cita que la acompañaban — no quedó ningún reemplazo. Efecto en Perfil: como ahí no hay encabezado con nombre (no hace falta repetir de quién es cada tarjeta — ver "Inicio: feed de novedades" más arriba), esa línea era el único lugar donde se veía la hora de una foto, cita o comentario puntual; al sacarla, esas tarjetas quedan sin hora visible en Perfil (la reseña final no se toca — su encabezado con nombre + hora sigue igual, en las dos pantallas). Se dejó así, con este mismo criterio: si hace falta, se puede sumar de nuevo más adelante, en otro lugar de la tarjeta.display: flex horizontal), y todo lo que iba debajo (el texto, la imagen, la fila de "me gusta") arrancaba desde el borde izquierdo de la tarjeta entera, sin alinearse con el nombre — quedaba una tarjeta con dos puntos de partida distintos en vez de una sola columna prolija. Ahora (el avatar solo, como su propio link al perfil) va en una columna de ancho fijo a la izquierda, y (el nombre, sin el avatar) encabeza una columna a la derecha que contiene todo lo demás. Solo aplica cuando hay avatar () — al principio, eso era Inicio siempre, y en Perfil solo la reseña final.No hizo falta ninguna migración — es un cambio de presentación nomás, ni una columna ni una política tocadas.
Hasta ahora, quien abría la app sin sesión iniciada caía directo al formulario de /login — sin ningún logo, ninguna explicación de qué es Libris, nada. Se agregó WelcomeScreen (src/screens/WelcomeScreen.jsx): una pantalla de bienvenida sobre el mismo fondo oscuro que ya usan la tarjeta de cita "Oscuro" (quoteCard.js) y el ícono/splash de la PWA — con el logo (versión crema, para ese fondo), una línea de qué es la app y un solo botón, "Empezar", que lleva a /login. Ahí adentro, LoginForm ya resuelve las dos cosas (iniciar sesión o crear cuenta, con un link para cambiar de una a la otra) — no hacía falta separar eso en la puerta de entrada.
src/app/page.js (la ruta /) es quien decide: antes, sin sesión, hacía redirect('/login'); ahora, sin sesión, devuelve <WelcomeScreen /> directo. El resto de rutas protegidas (/club/[id], /perfil/[id], una invitación) siguen mandando derecho a /login como siempre — esto solo cambia lo que ve alguien que abre la app "pelada" (el ícono de la PWA, o el dominio a mano), no ningún link puntual compartido.
Después, se pidió "más color" — el fondo oscuro liso quedaba correcto pero un poco chato. Se armaron 3 mockups (un resplandor coral/dorado sobre el fondo oscuro con la textura de lino de las tarjetas de club, este mismo degradado a pleno color, y un aura de color detrás del logo) y se eligió el degradado a pleno color: linear-gradient(160deg, var(--accent-500) 0%, var(--accent-600) 32%, var(--gold-700) 68%, #7A5A06 100%), coral arriba y dorado abajo, con la misma textura de lino que ya usan las tarjetas de club (CLUB_TEXTURE en MisClubesScreen.jsx — se repite acá en vez de importarse, es un detalle interno de ese archivo). El botón "Empezar" dejó de usar el <Button> del sistema de diseño — su variant="primary" es fondo coral, que sobre este degradado desaparece, y <Button> no deja pasar un color propio (su style interno pisa cualquier style que se le pase por props, mismo problema ya conocido de otras pantallas); quedó un link con la misma métrica (radio, padding, tipografía) pero fondo oscuro (--neutral-900), a mano.
Y después, "Empezar" más arriba — con justifyContent: 'space-between' entre 3 bloques (espacio vacío, contenido, botón), el botón quedaba pegado abajo de todo, lejos del texto que lo justifica. El botón se unió al mismo bloque que el logo/texto (un solo grupo, con gap) y ese grupo entero sube con un paddingTop: '18vh' en vez de repartirse por el alto completo de la pantalla — el aire vacío que sobra queda abajo, no partido entre arriba y abajo.
Dos preguntas seguidas — "¿se pueden compartir publicaciones de otros en el feed?" y "¿se pueden subir GIF?" — resultaron ser dos cosas que no existían todavía (lo que sí existía, ShareButton/toggleShareToFeed, solo deja compartir TU PROPIO comentario/nota de voz a Inicio; no había forma de reenviar algo de otra persona). Migración 039.
Repostear — RepostButton (src/components/RepostButton.jsx), ícono+número igual que LikeButton, en la fila de acciones de ActivityCard (reseña, cita, foto, y el comentario/nota de voz que ya se compartió a Inicio) — no vive en Comentarios del club, solo en Inicio/Perfil. Tocarlo llama a toggleRepost(kind, id) (src/app/actions/reposts.js), que inserta o borra una fila en la tabla nueva reposts (profile_id + comment_id o post_id, nunca los dos — una tabla para los dos tipos, a diferencia de los likes que tienen comment_likes/post_likes separadas, porque acá el mismo botón sobre la misma tarjeta cubre cualquier tipo de contenido).
recent_activity y profile_activity ganaron dos ramas más cada una (antes tenían "comentarios" y "fotos"; ahora también "comentarios reposteados" y "fotos reposteadas"), con created_at del repost —no del original— para que aparezca arriba, como algo fresco. El id de la fila sigue siendo siempre el del contenido ORIGINAL, reposteado o no: así "me gusta" de un repost sigue apuntando al mismo lugar que la publicación orgánica (ver más abajo, migración 040, por qué comentar es distinto). Como el mismo contenido puede aparecer dos veces (una vez publicado por su autor, otra reposteado por alguien que te sigue) —a propósito, es lo mismo que hace X— el id puede repetirse entre dos tarjetas distintas; el key de React en InicioScreen/PerfilScreen usa repost_id ?? id, no id a secas.
ActivityCard muestra, arriba de todo, "Fulana compartió esto" (o "Tú compartiste esto") cuando la tarjeta es un repost — pero el resto (avatar, nombre, contenido) sigue mostrando siempre al autor ORIGINAL, nunca a quien reposteó. Esto obligó a un cambio en PerfilScreen: antes author/isOwn se armaban con los datos de profile (la página que se está mirando) para cada tarjeta, porque siempre era la misma persona — con reposts eso ya no es cierto (podés ver, en el perfil de alguien, algo que esa persona reposteó de un tercero), así que ahora se arman con los datos de CADA item (item.profile_id/display_name/avatar_url, que las funciones ya resuelven al autor correcto) — para el resto de las tarjetas (no reposteadas) da exactamente lo mismo.
Qué se puede repostear lo valida la política de RLS de insert en reposts, con una función security definer nueva (can_repost_comment, mismo patrón que is_club_member): el SELECT normal de comments exige ser miembro del club, más angosto que la visibilidad real del feed (que también deja ver clubes públicos sin ser miembro) — sin esa función aparte, repostear una cita de un club abierto ajeno se hubiera rechazado por error, aunque esa cita sí se puede ver en Inicio.
GIF — el input de subir foto (PostComposer) ahora acepta image/gif además de jpeg/png/webp. Es la única excepción al recorte de siempre (PhotoCropModal): recortar ahí pasa por un <canvas>, que solo puede capturar un frame — recortar un GIF lo dejaría animado por dentro pero estático al mostrarlo. Un GIF salta directo a la vista previa con el archivo tal cual se eligió, y se sube sin tocar. El bucket post-photos (Storage) también tenía su propia lista de allowed_mime_types — hacía falta sumarle image/gif ahí también, o la subida se hubiera rechazado igual aunque el código de la app ya lo permitiera.
Después, se pidió separar los comentarios de un repost de los del post original — "cuando compartí algo y comenté en la publicación que compartí, ese comentario no debería aparecer en la publicación del usuario original, solo en la mía". Hasta acá, comentar desde un repost creaba una respuesta normal (parent_comment_id al comentario/cita/reseña original), así que aparecía en TODOS lados donde se ve el original — incluida la pantalla de Comentarios del club de quien lo escribió, que nunca reposteó nada. Migración 040.
repost_id — columna nueva, nullable, en comments y en post_comments (on delete cascade: dejar de repostear se lleva puesto lo que se comentó ahí, no tiene sentido dejarlo huérfano). null es un comentario normal, como siempre; con valor, el comentario vive scopeado a ESE repost puntual — no al contenido en general. Cada repost tiene ahora su propio hilo, separado tanto del original como de cualquier OTRO repost del mismo contenido (si dos personas repostean la misma cita, cada una tiene su propia conversación aparte, no comparten una).ActivityCard calcula repostId (activity.repost_id cuando la tarjeta es un repost) y se lo pasa a EngagementBlock/PhotoCommentsBlock, que lo mandan en el FormData de postReply/postPhotoComment si está presente. ComentariosScreen nunca lo pasa — ahí no hay reposts, todo sigue siendo repost_id is null como antes de esta migración.recent_activity/profile_activity: las ramas "orgánicas" (comentarios y fotos publicados por su autor) filtran repost_id is null en su subconsulta de respuestas; las ramas "reposteadas" filtran repost_id = <el id de ESE repost> — así cada tarjeta trae solo su propio hilo, nunca el ajeno.getChapterCommentCounts (la pastilla de "Comentarios" en Tu camino) y el select de comments en la pantalla de Comentarios del club ahora también filtran repost_id is null — sin esto, un comentario hecho en el repost de una cita de ese club se sumaba igual al conteo del club, o aparecía colgando del original en esa pantalla — justo lo que no debía pasar.comments ya exigía ser miembro del club (is_club_member) — correcto para un comentario normal, pero un repost puede verse en Inicio sin ser miembro (club público), así que se sumó una rama nueva con can_comment_on_repost (mismo patrón que can_repost_comment de la 039): cualquiera que pueda VER el repost puede comentarlo, sea o no quien lo compartió. Mismo criterio para post_comments. postReply también dejó de leer club_book_id/chapter_id del original con un select directo (sujeto a ese mismo RLS más angosto) y pasó a get_comment_context, una función aparte security definer — ese dato es solo estructural (a qué libro/capítulo pertenece), la autorización de verdad sigue viviendo en la política de insert.Se preguntó si se puede adjuntar una foto o GIF al comentar un capítulo — no se podía: NewCommentForm (Comentarios del club) era puro texto/cita, sin ningún input de archivo. Migración 041, solo para Comentario (kind = 'text') — una cita ya tiene su propia imagen (la tarjeta armada), no hacía falta sumarle otra cosa ahí.
comments.image_url — columna nueva, guarda el path del archivo (no una URL pública). A diferencia de "quote-cards" o "post-photos" (públicos, pensados para compartir afuera), la foto de un comentario va a un bucket privado, comment-photos — es una conversación de adentro de un club, mismo criterio que ya usan las notas de voz (voice-notes). El path es <club_book_id>/<profile_id>/<archivo>: el primer segmento habilita el chequeo de membresía (leer/subir, RLS de Storage calcado de voice-notes), el segundo el de dueño (borrar, calcado de post-photos/quote-cards).src/lib/commentPhotos.js (signCommentImageUrls) — como el path no es una URL, hay que firmarla del lado del servidor antes de mandarla al navegador. Se llama en los tres lugares donde un comentario con foto puede aparecer: la pantalla de Comentarios del club, Inicio y Perfil (si ese comentario se compartió al feed con una foto puesta) — mismo patrón que ya usaba esa página para firmar el audio de las notas de voz, generalizado a cualquier fila con un campo image_url.NewCommentForm — un botón "Agregar foto o GIF" (solo visible con Comentario elegido, no con Cita destacada) abre el selector de archivos; la vista previa es un thumbnail chico con una "x" para sacarla. Un JPG/PNG/WEBP pasa por compressImage (achica sin recortar, ya usado para la portada del libro); un GIF NO — mismo motivo de siempre, un <canvas> solo captura un frame y lo dejaría estático.postComment sube el archivo (si llega, "mejor esfuerzo": si falla, el comentario se publica igual, sin foto) a comment-photos y guarda el path en image_url. deleteComment ahora también borra ese archivo de Storage al borrar el comentario (mismo patrón que deletePost con las fotos de Perfil) — editar el comentario (updateComment) sigue siendo solo texto, la foto no se reemplaza, mismo criterio que ya rige para fotos y citas en el resto de la app.ComentariosScreen (CommentBody) y ActivityCard muestran la foto cuando existe: en Comentarios, debajo del texto; en el feed (Inicio/Perfil, si el comentario se compartió), ocupa el lugar que antes tenía la portada del libro de fondo — la portada seguía siendo el "algo visual" por default para un comentario de texto, ahora es la foto real cuando la hay.Después, se pidió carrusel (varias fotos, no una sola) — migración 042, todavía sobre lo mismo (Comentario de capítulo, mostrado en Comentarios del club, Inicio y Perfil).
comment_photos reemplaza a comments.image_url: una tabla aparte (comment_id, path, position) en vez de una columna de texto — hacía falta guardar el ORDEN del carrusel, algo que una sola columna no puede. La migración 042 migra la foto suelta de la 041 a esta tabla (posición 0) y borra la columna vieja — escrita para andar tanto si la 041 ya se había corrido como si no.is_club_member) a secas — pero recent_activity ya deja ver un comentario compartido de un club público sin ser miembro. Con esa política tal cual, la foto de ese comentario nunca se hubiera podido firmar para alguien así, aunque el comentario en sí sí se viera en Inicio — mismo tipo de bug de "RLS más angosto que la visibilidad real" que ya aparecieron con los reposts (migraciones 039/040). Se reemplazó por la misma regla que ya usan recent_activity/profile_activity: club sin invitación, o sos miembro.src/lib/commentPhotos.js (signCommentImageUrls) pasó de firmar un path suelto (image_url) a firmar un arreglo (image_urls) — mismo mecanismo, ahora sobre varios paths por fila.src/components/PhotoCarousel.jsx — nuevo, sin ninguna librería (scroll-snap nativo + puntitos abajo, que solo aparecen si hay más de una foto). Se usa tanto en ComentariosScreen (CommentBody) como en ActivityCard — ahí reemplaza al <div> de fondo de siempre solo cuando el comentario tiene fotos; sin fotos, sigue siendo la portada del libro como fondo, sin cambios.NewCommentForm — el selector de archivo pasa a multiple, hasta 6 fotos/GIF por comentario (MAX_COMMENT_IMAGES, espejada en postComment). Cada una se ve como su propio thumbnail chico con su "x" para sacarla, y "Agregar más" mientras no se llegue al tope.postComment ahora inserta el comentario primero (necesita su id para las filas de comment_photos) y recién después sube cada foto — "mejor esfuerzo" por archivo: una que falle no tira abajo a las demás. deleteComment borra todos los archivos de Storage de ese comentario (las filas de comment_photos se van solas, on delete cascade, pero eso no borra el archivo real — hay que pedirlo aparte, y antes de borrar la fila del comentario).Reporte: "subí un carrusel y no apareció" — migración 043, arregla un bug de la 042.
El comentario se publicaba bien y la foto se subía y se guardaba en comment_photos sin ningún error — el problema estaba al LEER: createSignedUrls rechazaba absolutamente todas las fotos, siempre, con el mismo mensaje genérico de Supabase ("Either the object does not exist or you do not have access to it") que no distingue "no existe" de "RLS lo bloquea". La causa: la política de SELECT de Storage que la 042 armó para ampliar la visibilidad hacía join public.clubs cl on cl.id = cb.club_id y después llamaba storage.foldername(name) — pero clubs también tiene una columna name (el nombre del club), y Postgres, sin ningún error ni aviso de ambigüedad, resolvía ese name suelto hacia la tabla más cercana en el FROM del subquery (cl.name) en vez de hacia el archivo real (storage.objects.name, la fila que la política evalúa). Terminaba comparando el club_book_id contra un pedazo del nombre del club, algo que nunca iba a coincidir — la política daba falso siempre, para cualquier foto, de cualquier comentario. La política de INSERT no tenía este bug (no hace join clubs, no hay ninguna otra columna name de por medio), por eso subir funcionaba perfecto mientras leer fallaba siempre: dos políticas del mismo bucket, evaluando cosas distintas.
Se encontró revisando los logs de Vercel en vivo (get_runtime_errors/get_runtime_logs, MCP de Vercel) con unos console.log/console.error temporales agregados en postComment, signCommentImageUrls y el select de comentarios/page.js — confirmaron que la escritura no fallaba nunca pero la firma sí, siempre, para cualquier foto de cualquier comentario — y confirmando la política exacta instalada con select policyname, cmd, roles, qual from pg_policies where schemaname='storage' and tablename='objects', que mostró el qual real con (storage.foldername(cl.name))[1] en vez de (storage.foldername(storage.objects.name))[1]. Esos logs temporales ya se sacaron del código; el fix (calificar storage.objects.name explícitamente) queda en la migración 043.
La pastilla de "N comentarios" (Tu camino) ahora también dice si hay nota de voz o foto/GIF. Antes era un solo número sin distinguir tipo. Se armaron 3 mockups visuales (un artifact aparte, con los colores/tipografía reales de la app) para elegir cómo se vería — pastilla por tipo, una sola pastilla con íconos + total, o la pastilla de siempre con puntitos de color — y se eligió la del medio: una sola pastilla, con el ícono de "mic" y/o "image" antes del ícono de comentario, seguidos del total (sin desglosar cuántos hay de cada tipo). getChapterCommentCounts (src/lib/clubDetail.js) pasó de devolver un número por capítulo a { total, hasVoice, hasPhoto } — trae kind y el embed comment_photos(id) de cada fila (solo para saber si existe alguna, no cuántas) y arma esas tres banderas acá mismo. SideExtras (ChapterPath.jsx) agrega esos íconos sin color propio — ni "mic" ni "image" tienen uno en ningún otro lugar de la app (AvatarUploader, VoiceRecorder), así que tampoco se les inventa acá: van del mismo color que el resto de la pastilla.
La foto de perfil real, no solo iniciales, en comentarios y respuestas. Avatar (src/design-system/components/core/Avatar.jsx) siempre soportó una foto real vía su prop src — cae a las iniciales solo si no se la pasan — pero en tres lugares distintos nunca se la pasaban, así que siempre mostraban iniciales tuviera o no foto de perfil quien comentó:
ComentariosScreen.jsx (comentarios y reseñas finales del club) — la consulta en comentarios/page.js ni siquiera traía avatar_url (profiles(display_name), sin el campo); se agrega, y los dos <Avatar> (comentario, reseña) pasan src={comment.profiles?.avatar_url} / src={review.profiles?.avatar_url}.EngagementBlock.jsx (respuestas anidadas, tanto en Comentarios del club como en el feed de Inicio/Perfil) — el dato ya estaba disponible para Comentarios del club (mismo array de arriba), solo faltaba pasarlo al <Avatar> de cada respuesta.PhotoCommentsBlock.jsx (comentarios de una foto, en el feed) — mismo caso.Para el feed de Inicio/Perfil, el dato ni siquiera existía: recent_activity/profile_activity arman cada respuesta a mano con jsonb_build_object, y su 'profiles' anidado solo llevaba 'display_name'. Migración 044 agrega 'avatar_url' a esas 8 respuestas armadas a mano (4 ramas × 2 funciones) — sin tocar ninguna otra cosa: como replies ya era una columna jsonb, esto solo enriquece lo que hay adentro de ese jsonb, no cambia el returns table(...) de ninguna función.
Bug: en el broche "+" de Tu camino, el botón "Publicar" del modal se veía como una tira naranja apenas asomando arriba del tab bar de abajo (Inicio/Recursos/Club/Perfil). El componente compartido Modal (src/design-system/components/feedback/Modal.jsx, usado en TODOS los modales de la app — "Compartir" en Perfil, "Agregar" en Tu camino, los de editar cita/comentario/nota de voz, el de preguntas de capítulo, etc.) se montaba como un <div position:fixed> más, adentro del árbol normal de la página. Casi siempre alcanza, pero cuando el modal se dispara bien adentro de AppShell (como el broche "+", varios niveles de componentes más abajo), el tab bar de abajo (position: sticky, con su propio contexto de apilamiento) terminaba pintándose ENCIMA del modal en vez de abajo — tapando buena parte del botón principal.
Modal ahora se monta con createPortal (react-dom), directo en document.body — deja de vivir adentro del árbol de AppShell por completo, así que ya no importa en qué parte de la página se dispare ni qué contexto de apilamiento tenga alrededor: siempre pinta por encima de todo. Cambio en un solo archivo compartido, alcanza a los diez y pico de lugares que ya usaban Modal sin tocar ninguno de ellos.typeof document === 'undefined' devuelve null antes de intentar el portal — nunca hace falta en la práctica (todo modal de esta app arranca cerrado y se abre recién con un click, ya del lado del cliente), pero deja el componente a salvo si alguna vez se renderizara server-side con open en true desde el arranque.Hasta acá, borrar un comentario, una cita o una nota de voz era algo que solo podía hacer quien lo había publicado — ni siquiera un administrador del club podía sacar algo que no correspondía con las dinámicas del club (spam, algo fuera de lugar). Se conversó primero el alcance antes de tocar código — tres preguntas con consecuencias bien distintas cada una:
Qué tipos de contenido — comentarios de texto (con o sin foto/GIF), citas destacadas y notas de voz, sí; reseñas finales del libro, no — quedan afuera a propósito.
Qué queda en el lugar del comentario borrado — nada: desaparece del todo, mismo comportamiento que ya existía al borrar lo propio. Se dejó de lado la alternativa de un aviso tipo "Comentario eliminado por un administrador" en su lugar (pedía guardar el comentario como borrado en vez de eliminar la fila, y tocar cada pantalla que lista comentarios) — más simple así, sin una pantalla nueva que mantener.
Si la persona se entera — sí: ver más abajo, la notificación nueva.
RLS de comments — la política de borrado (migración 028) pasa de "solo el dueño" a "el dueño, o un administrador del club si el tipo es text/quote/voice" (review queda afuera de la excepción de admin, calcado del kind in (...) de siempre). Usa public.is_club_admin(club_id), la misma función security-definer que ya usan el resto de las políticas de administrador (migración 009) — no hizo falta escribir una nueva.
isClubBookAdmin (src/lib/clubAdmin.js, nuevo) — desde el servidor, un helper chico para saber si sos admin del club dueño de un club_book_id puntual (dos selects simples, club_books → club_members, ambos ya legibles por cualquier miembro). Lo usan las tres acciones de borrar (deleteComment/deleteQuote en clubs.js, deleteVoiceComment en media.js): antes filtraban directo por profile_id = tu id; ahora primero traen la fila completa, y si no sos el dueño, chequean si sos admin — si ninguna de las dos, error antes de intentar nada. La política de RLS queda como red de seguridad final, por si el chequeo de la acción tuviera algún agujero.
ComentariosScreen.jsx — cada fila (CommentRow) recibe un nuevo prop isAdmin (de comentarios/page.js, club.role === 'admin', mismo dato que ya usa /club/[clubId]); con canModerate = isAdmin && !isOwn, el menú de 3 puntos aparece también en contenido ajeno, pero solo con "Eliminar" — nunca "Editar" (PostMenu ya sabe esconder esa opción cuando onEdit es , así que alcanzó con pasarle solo si ). queda afuera del alcance de moderación (las reseñas no son borrables por un admin) pero sí se tocó por otro motivo — ver el bug de abajo.
Hasta acá, un club quedaba pegado para siempre al primer libro que se elegía al crearlo — createClub era el ÚNICO lugar de toda la app que insertaba en club_books. Terminar un libro y querer leer otro en el mismo club no tenía ningún camino: ni un botón, ni una acción, nada — la única salida real era armar un club nuevo de cero, perdiendo el nombre, los integrantes y el historial.
Dos preguntas se conversaron antes de construirlo (igual que con la moderación, arriba): qué pasa con los comentarios/progreso del libro que se deja, y desde dónde se arranca uno nuevo. Se optó por lo más simple de las dos veces: el libro anterior se archiva (nada se borra, pero deja de verse — sin pantalla propia para volver a repasarlo, se puede sumar después si hace falta) y el botón vive en Preferencias del club, junto a "Gestionar capítulos".
club_books nunca tuvo policy de UPDATE (solo lectura e inserción, desde el schema base) — hacía falta una para poder desactivar el libro que se deja. De paso, la policy de INSERT (migración 002) dejaba a CUALQUIER miembro crear un club_books — nunca se explotó porque no había ningún botón para esto, pero ahora que sí lo hay, queda tan restringida a administradores como el resto de las acciones de esta pantalla (public.is_club_admin, la misma función de siempre).startNewClubBook (clubs.js, nuevo) — mismos tres campos que "Crear club" (título, autor, cantidad de capítulos; reutiliza findOrCreateBook y chapterRows, ya existentes). A diferencia de addChapter (que confía del todo en RLS), acá SÍ se chequea admin del lado del servidor — cerrar el libro de todo un club es bastante más grande que sumar un capítulo. El orden importa: primero se arma el libro nuevo completo (libro + club_books + capítulos) y RECIÉN AHÍ se desactiva el viejo — no es atómico (mismo trade-off que ya acepta createClub), pero si algo falla a mitad de camino, el club se queda con el libro de siempre activo, nunca sin ninguno. Al terminar, redirige directo a la pantalla del club — ya con el libro nuevo.StartNewBookModal (src/screens/StartNewBookModal.jsx, nuevo) — el mismo formulario que "Crear club" (OnboardingScreen.jsx), con un aviso arriba de todo avisando que el libro actual queda archivado. Se abre desde un botón nuevo, "Empezar un libro nuevo", en "El libro en curso" (PreferenciasScreen.jsx) — visible solo para administradores, igual que "Gestionar capítulos".Tu camino, Comentarios del club, getChapterCommentCounts, getChaptersWithQuestions, "Actividad del club", todo lo que muestra algo del libro en curso ya parte de getActiveClubBook (.eq('is_active', true)) — apenas el libro nuevo queda activo y el viejo archivado, el resto de las pantallas lo siguen automáticamente, sin ningún filtro nuevo que agregar.getActiveClubBook — salen de recent_activity/profile_activity, que no filtraban por club_books.is_active. Una cita, reseña o comentario de un libro que el club ya dejó atrás seguía apareciendo ahí igual (correcto: nada se borra al archivar), pero su link "Ver el resto de los comentarios en el club" llevaba a Comentarios del club, que siempre muestra el libro ACTIVO — terminabas en el libro/capítulo equivocado, sin ningún aviso. Las dos funciones suman una columna, ( de esa fila; fijo en lo que no cuelga de ningún club, como fotos o citas de perfil), y la usa para, cuando da , cambiar ese link por un aviso sin destino ("Es de un libro anterior del club..."). No hizo falta tocar ni : las dos ya pasaban el entero como prop, así que la columna nueva les llega sola.El punto que había quedado pendiente al construir "Empezar un libro nuevo" (arriba): el libro que se archiva no tenía NINGUNA pantalla para volver a verlo — ni su camino, ni sus comentarios, ni la reseña que hubieras escrito. Antes de armarlo se conversaron las mismas dos preguntas de siempre: cómo se llega si el club ya tuvo más de un libro anterior, y qué se puede hacer una vez adentro. Se eligió una lista para elegir cuál (no solo el último) y solo lectura, salvo tu propia reseña, que se sigue pudiendo editar/borrar como siempre.
club_books/chapters/comments/reading_progress, todas de la RLS de siempre) confirmó que ninguna filtra por is_active: la única barrera para ver o para seguir editando tu reseña de un libro que el club ya dejó era el CÓDIGO de la app (getActiveClubBook, hardcodeado en dos lugares), no la base.getClubBookHistory, clubDetail.js; PastBooksList.jsx) — una sección más en el mismo carrusel de "Tu camino" (SwipeableSections, que ahora acepta initialIndex para que "Tu camino" se siga viendo primero), ANTES de "Tu camino" — deslizar a la izquierda es como se llega, deslizar a la derecha desde "Tu camino" sigue yendo a "Actividad del club", exactamente como antes. Si el club nunca tuvo un libro anterior, esta sección ni se arma — el carrusel queda igual que siempre. Cada fila muestra tus propias fechas de lectura si las tenés (misma fuente que "Mi biblioteca": reading_progress, no hay ninguna fecha "de cierre" guardada en club_books mismo).PastChapterPath.jsx (nuevo) — el camino de un libro puntual, de solo lectura: mismo degradé y nodos que ChapterPath (duplicado a propósito, sin importar de ahí — ese componente da por sentado que siempre hay progreso para tocar, "+", preguntas, compañeros; acá no aplica nada de eso), pero sin tocar nada — se pide bajo demanda (getPastClubBookDetail, clubs.js) recién al abrir un libro de la lista, no junto con el resto de "Tu camino".comentarios/page.js ahora acepta ?libro=<club_book_id> además de resolver siempre el libro activo por default; readOnly sale de si ESE libro sigue activo o no (no de si vino el parámetro). Con readOnly, ComentariosScreen apaga la barra/formulario de agregar, y el menú de editar/borrar de cualquier cosa que NO sea tu propia reseña — cita, comentario, nota de voz, aunque sea tuya, y la moderación de un administrador también queda apagada. EngagementBlock suma su propio readOnly: se sigue viendo el hilo de respuestas que ya había (con su "me gusta", que no es agregar nada nuevo), pero "Comentar" desaparece del todo, tanto el botón principal como el "Responder" de cada respuesta puesta. El menú de la reseña (ReviewCard) es la ÚNICA excepción, a propósito: sigue con "Editar"/"Eliminar" siempre, venga o no readOnly — es lo único que se pidió que siguiera editable.Se pidió dar vuelta la premisa de "Empezar un libro nuevo" (migración 056): que el libro anterior NUNCA se cierre solo — que la gente pueda seguir comentando y marcando progreso ahí para siempre — y que un club pueda tener más de un libro EN CURSO al mismo tiempo, si quiere leer dos cosas en paralelo.
Dos preguntas se conversaron antes de construirlo (mismo criterio de siempre): si iba a haber un libro "principal" destacado o todos iguales en una lista, y si "Empezar un libro nuevo" iba a preguntar cada vez qué hacer con el libro que ya había. Se eligió que sí haya un principal (menos cosas rotas: la tarjeta de "Mis clubes de lectura", las notificaciones, todo lo que ya asumía "el" libro del club sigue andando igual, apuntando al principal) y que sí pregunte cada vez (un Chip en el mismo formulario de siempre: "Pasa a ser el principal" / "Se lee en paralelo").
club_books separa dos preguntas que is_active venía contestando juntas — is_active (¿se puede seguir participando? ahora puede haber VARIAS filas en true por club a la vez, no una sola) e is_principal, columna nueva (¿es el libro que se destaca? como mucho una por club — un índice único de verdad lo garantiza, club_books_un_solo_principal_por_club, antes esto solo lo cuidaba el código). Los libros existentes no cambian de comportamiento: migrar fue tan simple como "el que hoy es is_active = true pasa a ser además is_principal = true" — había exactamente uno por club, por la propia regla vieja.getActiveClubBook se renombra a getPrincipalClubBook (activeClub.js) y cambia su filtro de is_active a is_principal — mismo uso en todos lados (la tarjeta de "Mis clubes de lectura", "Tu camino" por default, Comentarios por default, "Gestionar capítulos" por default): siguen mostrando "el" libro del club sin ningún cambio de comportamiento, solo que ahora ese "el libro" es explícitamente el principal, no "el único activo que había".startNewClubBook ya NO toca is_active del libro que se deja — arma el libro nuevo completo (libro + club_books + capítulos, siempre is_principal: false al insertar) y, solo si se eligió "Pasa a ser el principal", recién ahí mueve is_principal del viejo al nuevo (dos updates: saca la marca, la pone — el índice único no deja tener dos a la vez). Si se eligió "Se lee en paralelo", no se toca nada del libro que ya había — el nuevo libro queda con su propio camino, sus propios comentarios, sin desplazar a nadie.?libro=<club_book_id> (club/[clubId]/page.js, mismo patrón que ya usaba Comentarios desde la migración 058). Un libro en paralelo se ve y se usa EXACTAMENTE igual que el principal — mismo ChapterPath interactivo de siempre, nada de solo lectura. Un libro archivado, en cambio, redirige al club a secas si alguien llega con un ?libro= viejo — esa vista es la de solo lectura de la migración 058, no "Tu camino" de verdad.getClubOtherBooks en clubDetail.js, PastBooksList.jsx) ahora lista TODO lo que el club tiene aparte del libro que se está mirando — en paralelo y archivado mezclados, cada fila con una etiqueta (Principal/En paralelo/Archivado). Tocar uno EN PARALELO navega directo a su "Tu camino" (); tocar el PRINCIPAL (si se estaba mirando uno en paralelo) vuelve al club a secas; tocar uno ARCHIVADO sigue abriendo la hoja de solo lectura de siempre (), sin cambios. Un administrador ve, además, una pastilla para "Gestionar capítulos" en cada fila en paralelo — el único lugar nuevo donde hacía falta, porque el de Preferencias sigue siendo solo para el principal./privacidad)Pública, sin sesión — hacía falta para publicar en Play Store/App Store (las dos la piden como link de ficha) y hasta ahora no existía ninguna. PrivacidadScreen.jsx describe lo que Libris junta DE VERDAD, relevado directo del código (no una plantilla genérica): qué datos, para qué se usan, que viven en Supabase (encargado del tratamiento) y se sirven desde Vercel, que no hay anuncios ni rastreo de terceros, y cómo pedir una copia o el borrado de los propios datos. Se llega también desde ADENTRO de la app — el menú de tres puntos del propio Perfil suma "Privacidad" junto a "Cerrar sesión" — porque las dos tiendas piden que sea encontrable ahí, no solo desde afuera.
[email protected], provisorio — cambiar en PrivacidadScreen.jsx (dos apariciones) el día que haya uno definitivo.Libris ya era instalable como PWA desde Chrome; esto la envuelve además como una app de Android de verdad (Trusted Web Activity, vía Bubblewrap) para poder publicarla en Play Store — algunas personas ya estaban usando la PWA y se pidió sumar la ficha en la tienda.
android/twa-manifest.json — la config de la app nativa (nombre, colores, ícono, packageId cl.libris.app, apuntando al dominio real libris.cl). Se armó a mano en vez de con el asistente interactivo de Bubblewrap, pero NO a ojo: se validó contra el código fuente real de @bubblewrap/core (la función que arma este mismo archivo a partir del manifest.webmanifest de la propia PWA) para que quede idéntico a lo que el asistente hubiera generado solo. Es el único archivo de android/ que se versiona — el resto (app/, gradlew, etc.) lo regenera Bubblewrap de cero en cada build, y la clave de firma nunca va al repo (ver abajo)..github/workflows/android-build.yml — arma el .aab (para subir a Play Console) y un .apk (para probar en un celular), corriendo bubblewrap update + bubblewrap build de punta a punta sin ningún prompt interactivo (contraseñas por variable de entorno, BUBBLEWRAP_KEYSTORE_PASSWORD/BUBBLEWRAP_KEY_PASSWORD — documentado así en el propio README de @bubblewrap/cli, justamente para CI). Se dispara a mano (workflow_dispatch) desde la pestaña Actions de GitHub — no hace falta correrlo en cada push, porque el contenido de la PWA se actualiza solo (vive en Vercel); solo hace falta un build nuevo si cambia el empaquetado nativo (ícono, versión, etc.).dl.google.com, bloqueado en el entorno donde se desarrolla Libris con Claude Code — los runners de GitHub sí tienen internet completo.keytool (no hace falta el SDK para eso) — se entregó directo a quien pidió este build, para que la guarde a buen resguardo. Si se pierde, no hay forma de volver a actualizar la app ya publicada en Play Store — habría que publicarla de cero, perdiendo reseñas e instalaciones. Vive como dos secrets de GitHub Actions (ANDROID_KEYSTORE_BASE64, el archivo en base64; ANDROID_KEYSTORE_PASSWORD, la contraseña — la keystore es PKCS12, así que sirve la misma para la clave y el almacén), nunca en el repo.libris.cl (no al .vercel.app) — public/.well-known/assetlinks.json ya está en el repo, con la huella SHA-256 de la clave de firma (keytool -list), para que Android verifique que la app y el sitio son del mismo dueño y la abra sin la barra de Chrome encima. Si el día de mañana se genera una clave de firma NUEVA (por ejemplo, si esta se pierde y hay que empezar de cero), este archivo hay que actualizarlo con la huella nueva.editorial_items alimenta las solapas Guías/Cursos de Recursos. No hay panel de administración: se carga y edita desde el Table Editor de Supabase. is_published controla qué se ve.
El SQL Editor de Supabase corre cada script en una sola transacción: si una sentencia falla al final, se revierte todo, incluidos los buckets creados al principio. supabase/diagnostico-storage.sql lista qué quedó realmente creado. Si faltan los buckets, se pueden crear a mano desde Storage → New bucket (book-covers público, voice-notes privado) y después correr solo el bloque de políticas de la migración 005.
reading_progress).percent viejo (medía avance dentro del capítulo) hasta la próxima vez que esa persona actualice su progreso — no se recalculan retroactivamente.Paleta Electric Coral sobre fondo blanco (page y cards — el crema del brief original se descartó a pedido): coral (--accent-500) = progreso/acciones primarias, dorado (--gold-500) = citas destacadas/contenido editorial, verde (--success) = actividad social (otros clubes, feed). Tipografía DM Serif Display (headlines) + Karla (UI/cuerpo) — antes Bricolage Grotesque + Plus Jakarta Sans; se probaron 4 parejas nuevas sobre pantallas reales (héroe del club) y esta fue la elegida, por sentirse más "editorial, de club de lectura de verdad" que la geométrica de antes. Íconos vía Lucide (lucide-react).
DM Serif Display solo trae la variante regular (sin corte bold propio): los títulos que piden fontWeight: 700/800 reciben un bold sintetizado por el navegador (engrosa el trazo, no usa formas dibujadas a propósito). Se probó en varias pantallas y se ve bien a los tamaños que usa la app — quedó así. Si en algún lugar puntual se ve "embarrado", el arreglo es bajar ese fontWeight a 400/500 en ese lugar.
Ver design-reference/readme.md para el detalle completo de fundamentos visuales y contenido, y las notas de sustitución — este sistema de diseño se armó desde un brief sin logo, fuentes propias ni set de íconos, así que ningún wordmark/tipografía/ícono actual debe tratarse como marca final:
public/logo-libris.png, wordmark "LiBRiS" en negro sobre transparente) — se usa en la pantalla de Login reemplazando el texto plano que había antes.public/icons/*.png, src/app/icon.png): ya no son placeholders — es el isotipo "punto + S" (el punto de la "i" final del logo, sobre la "S", como un signo propio) en blanco sobre coral. Fuente en design-reference/brand/ (isotipo-punto-s.png en negro, sin fondo, y logo-libris-wordmark.png) por si hace falta regenerar algún tamaño.design-system/tokens/typography.css) — swap directo si hay tipografías con licencia propia.profile_quote_likespost_likesreposts.comment_idcommentsprofile_quotesprofile_activityrecent_activitykind = 'quote'club_idnullclub_idActivityCardEngagementBlockSin tarjeta armada para compartir todavía — se muestra con el mismo tratamiento genérico que ya tiene cualquier cita sin imagen guardada. Ver migración 050 más abajo: ese tratamiento genérico ya no es la única opción.
followProfileunfollowProfilegetClubMembers (src/lib/activeClub.js) — club_members con profiles embebido, mismo cruce que ya usaban otras pantallas del club. Quién de esos integrantes ya seguís se resuelve con una sola consulta a follows por toda la lista de clubes (no una por integrante), cruzada del lado del servidor antes de mandarle los datos a la pantalla.La portada, más grande, hasta la barra de progreso — pasó por tres vueltas. Primero se probó la portada ocupando toda la franja izquierda de la tarjeta (de punta a punta); no era lo que hacía falta. Se volvió a la miniatura con sombra, solo agrandada (56×76 → 84×114). Después se pidió agrandarla un poco más, hasta que llegara a la barra de progreso — así que la barra se sacó de su fila propia (que cruzaba toda la tarjeta) y pasó a vivir adentro de la misma columna que el título/autor, alineada al borde inferior vía justify-content: space-between; la portada creció a 84×130 para llenar exactamente esa altura (height: COVER_H en la fila, flex-1 la estira). Como resultado, la portada ya no se ve completa sin recortar — se ajusta con object-fit: cover a esa caja más alta — y la barra ya no ocupa todo el ancho de la tarjeta: arranca donde termina la portada, no antes. El nombre del club arriba quedó intacto.
Barra siempre continua, sin capítulos al lado del autor — la barra de progreso "en pips" (un punto por capítulo, cuando el libro tenía pocos) se sacó: ahora es siempre la barra continua, sin importar si el progreso se registra por capítulo o por página. computeHeroProgress (src/lib/heroProgress.js) se simplificó a la par — ya no calcula pips ni unit (el "9 capítulos" que iba pegado al autor), solo progressMeta (la etiqueta chica de abajo del %). El autor, si el libro no tiene autor cargado, ahora no deja un punto (" · ") colgando — se probó igual con unit, así que se corrigió de paso.
% y página, integrados a la barra — se probaron 4 mockups de dónde poner el % y la cantidad de páginas (apilados junto al título como estaba, en fila propia encima de la barra, % arriba con la página pegada a la barra, o todo junto a la barra) y se eligió esta última: título/autor quedan solos arriba, sin nada compitiendo al lado, y el % (club.percent, tamaño reducido a 14px para que entre en la fila) se muestra pegado al extremo derecho de la barra misma, con club.progressMeta como pie de foto chico debajo, alineado a la derecha.
Un degradado por posición, con textura de lino — la tarjeta pasó de coral sólido a un degradado fuerte (claro arriba, tinta oscura abajo) con una trama de lino sutil encima (dos repeating-linear-gradient cruzados a 45°/-45°, mix-blend-mode: overlay). Se probaron varios pasos antes de llegar acá: primero un degradado sutil dentro del mismo coral, después degradados más marcados en distintos colores, y por último dos texturas (grano y lino) sobre esos colores — el lino fue el elegido. CLUB_PALETTE (src/screens/MisClubesScreen.jsx) tiene 4 combinaciones {degradado, color de anillo — para el borde de los avatares y del "+N"}: verde, violeta, azul y rosa. El color de cada tarjeta sale de su posición en la lista (index % 4), no de una elección de quien administra el club ni de un color fijo por club — mismo criterio que ya usa Avatar.jsx para elegir color por persona, sin pedirle nada a nadie. Como getMyClubs ordena por joined_at (nunca por actividad reciente), la posición — y por lo tanto el color — de cada club es estable en el tiempo, y el ciclo de 4 garantiza que dos tarjetas vecinas nunca compartan color (solo se repite entre clubes que ya no compiten por atención en la misma pantalla). La textura y el degradado van en una capa position: absolute propia dentro de la tarjeta (que ahora es position: relative; overflow: hidden); el resto del contenido (encabezado, portada, barra, integrantes) se marcó position: relative para pintarse por encima de esa capa.
streak_count/last_activity_date en reading_progress, migración 035) — días seguidos marcando progreso en ESE libro de ESE club (no es una racha global de la cuenta). Se calcula en el servidor, dentro del propio updateProgress: mismo día no suma de nuevo, al día siguiente suma uno, con un salto de 2+ días se reinicia en 1. Vivía integrada en el nodo del capítulo actual del camino — un halo cálido y una insignia con llama, sin tarjeta aparte — y solo se mostraba a partir de 2 días (con 1 no es realmente una racha todavía).
ChapterPath ya no dibuja la llama (🔥) sobre el nodo actual ni recibe streakCount en sus props; ClubScreen la sigue pasando igual (queda ahí, sin uso, no rompe nada). El servidor sigue contando los días corridos en cada updateProgress exactamente igual que antes — la columna streak_count sigue viva, no se perdió ni un día de racha de nadie — para que, si se pide de vuelta, alcance con volver a dibujar la insignia en el nodo, sin recalcular nada desde cero.getChapterCommentCounts, src/lib/clubDetail.js: cuenta chapter_id de la tabla comments, sin traer texto). No hay pastilla si el capítulo no tiene ninguno.
/club/[clubId]/comentarios?capitulo=<id> — la pantalla de Comentarios ganó soporte para ?capitulo= (prop initialChapterId en ComentariosScreen, cae al primer capítulo si el id no es válido) para abrir ya parada en el capítulo que corresponde. Entrando así (por un capítulo puntual — hoy la única forma de entrar, desde que se sacó el ícono general del encabezado), la pantalla se queda corta a propósito: no muestra las reseñas finales (son del libro entero, casi siempre con spoilers del final) ni el selector para cambiar de capítulo (solo el nombre, como texto — nada de "Ver otro capítulo": entraste a ver ESE capítulo) — y el orden se invierte: primero lo que ya se comentó, el cuadro para dejar tu propio comentario/cita/nota de voz queda al final, después de leer lo que ya se dijo. ComentariosScreen conserva el modo "entrada general" (selector completo, cuadro antes que la lista) por si en algún momento se vuelve a exponer una entrada sin capítulo — hoy no hay ningún link que lo dispare.?capitulo= también en los links que salen del club (migración 036) — al compartir un comentario/nota de voz de un capítulo a Inicio (shared_to_feed), la tarjeta ahí (ActivityCard, "Ver el resto de los comentarios en el club") llevaba siempre a la pantalla general de Comentarios, sin importar de qué capítulo se tratara. recent_activity y profile_activity no traían chapter_id, así que no había con qué armar el link puntual — ahora sí lo traen, y el link usa ?capitulo=<id> cuando lo tiene (las reseñas siguen sin capítulo, van al link general, correcto: son del libro entero). Mismo arreglo en ClubActivityFeed (la "Actividad del club" que ya vive en Tu camino) — buildClubActivity (src/lib/clubActivity.js) ahora expone el chapterId de cada tarjeta agrupada, no solo lo usaba para el texto.updateProgress con mode: 'finished' que ya usaba el modal completo, y dispara el mismo flujo de siempre: se cierra, y se abre FinalReviewModal para dejar la reseña. Se probaron varias formas antes de llegar a esta (banner aparte, nodo con trofeo, 5 íconos alternativos) — un nodo más del camino, no un cartel aparte.voice_transcriptprofile_activityrecent_activitydeletePost borra también el archivo de audio de Storage al borrar el post (mismo patrón que ya sigue con la foto), y updatePost deja de exigir caption cuando el post tiene una nota de voz propia (antes solo miraba image_url).toggleTab, en PostComposer) — es la única forma de "salir" de Cita/Foto·GIF/Voz sin cerrar el panel entero, ya que no hay ningún botón "Comentario" al que volver. Por el mismo motivo, el link "Agregar foto o GIF" que vivía adentro de esa vista de texto también se saca: ya existe la pestaña "Foto/GIF" dedicada a eso, tenerlo repetido ahí solo confundía — TextOrPhotoTab (compartido por la vista de texto y la pestaña "Foto/GIF") suma showAddPhotoLink, en false para la primera y en su default true para la segunda, que sigue necesitándolo para poder elegir otra foto si se saca la que había.postAction a VoiceRecorder — caía en su default, postVoiceComment (la nota de voz de CLUB, media.js), que exige clubBookId. Se agrega publishVoicePost (un adaptador de una sola línea: (formData) => createPost(null, formData)) porque VoiceRecorder llama a postAction(formData) con un solo argumento, y createPost es una server action de la forma (prevState, formData) — ya se usaba así, con null, desde TextOrPhotoTab.VoiceRecorder, EditVoiceModal) y "Ver/Ocultar transcripción" pasa a "Ver/Ocultar mensaje" (VoiceNotePlayer). Por dentro no cambia nada — sigue siendo el mismo campo (transcript en el formulario, voice_transcript en la base), evitando una migración innecesaria por un cambio que es solo de cómo se presenta.toggleTab, en ChapterCommentsPanel, mismo patrón que PostComposer). NewCommentForm suma showAddPhotoLink (default true, para no cambiarle el comportamiento a la pantalla de Comentarios del club, que la sigue usando tal cual): en false para la vista de texto por default, en true solo en la pestaña "Foto/GIF" — mismo criterio que ya tenía showAddPhotoLink en TextOrPhotoTab.ChapterCommentsPanel era un panel inline que empujaba el resto de Tu camino hacia abajo (aparecía metido entre los nodos del capítulo); ahora es el mismo Modal compartido que ya usa "Compartir" en el Perfil — una hoja que sube desde abajo, tapando la pantalla. El título ("Agregar · {capítulo}") y el botón de cerrar los pone el propio Modal, así que se saca el header a mano que tenía antes (con su propio botón × y Icon).hideSpoilerOption nuevo en NewCommentForm, showSpoilerOption en false en la llamada a VoiceRecorder). La pantalla de Comentarios del club (ComentariosScreen) sigue mostrándolo igual que siempre — ahí sí tiene sentido, es donde se lee el hilo completo de un capítulo.VoiceRecorder era 5 minutos, y grababa al bitrate que el navegador elegía por default — pensado para audio en general, no para una voz hablando. Una nota de voz, sea de club o de perfil, es una reacción corta, no una conversación grabada: MAX_SECONDS baja a 90, y se suma audioBitsPerSecond: 32000 (32 kbps, mismo orden de magnitud que usa WhatsApp para notas de voz) al MediaRecorder — el equivalente, para audio, de lo que compressImage ya hacía con las fotos: bajarle el peso ANTES de subir. Como VoiceRecorder es un solo componente para los dos casos, el cambio alcanza a Club y Perfil a la vez, sin tocar nada en ChapterPath/ComentariosScreen/PostComposer. Con 90 segundos a 32 kbps, un audio real pesa bastante menos de 1 MB, así que los topes de tamaño en bytes (MAX_AUDIO_BYTES en media.js, MAX_VOICE_BYTES en posts.js) bajan de 10 MB a 2 MB — quedan como red de seguridad, no como el límite que de verdad importa — y el file_size_limit de los dos buckets de audio (post-voice-notes, nuevo en esta migración, y voice-notes, el de club desde la migración 005) baja igual a 2 MB en Storage, para no dejar el servidor aceptando de más por detrás de la validación de la app.deleteChapterQuestionidChapterCommentsPanel suma una quinta pestaña, "Pregunta" (ícono clipboard-list), visible solo si isAdmin (nuevo prop, de ChapterPath → ClubScreen, que ya lo tenía calculado para el ícono de Preferencias). AdminQuestionTab pide la lista de preguntas que ya tenga ese capítulo (getChapterQuestions) antes de mostrar ChapterQuestionsManager.ChapterPath.handleTap: justo después de que updateProgress marca el capítulo, getChapterQuestions(chapterId) trae todas las preguntas de ese capítulo con un flag answered por cada una (calculado contra las respuestas de quien está mirando) y arma una cola con las que todavía no se respondieron. Si la cola no está vacía se abre ChapterQuestionModal con la primera; un indicador "N de M" aparece en el encabezado solo cuando hay más de una pendiente. Nunca vuelve a saltar sola una pregunta ya respondida (no hay ninguna otra forma de re-abrirla en esta v1).onNext (avanzar) y onClose del Modal (cerrar del todo) vivían los dos detrás del mismo botón, así que cerrar y pasar a la siguiente eran, sin querer, la misma acción. Ahora la × (onDismiss) siempre cierra la cola entera, aunque queden preguntas sin ver; para avanzar hay un botón aparte, "Siguiente pregunta →" (ícono arrow-right), que solo aparece cuando efectivamente queda otra después de esta (position.index < position.total) — en la última de la cola no hace falta, cerrar con la × ya alcanza porque no hay a dónde más ir.answerChapterQuestion devuelve el resultado agregado en la misma llamada — el panel pasa directo de "elegí una opción"/"escribí tu respuesta" a mostrar los resultados, sin un segundo viaje al servidor. En una pregunta abierta, la lista de "respuestas del club" excluye la propia (esa ya se ve aparte, arriba, con lo que la persona acaba de escribir).saveChapterQuestion fallaba en silencio. Mismo arreglo que ya se le había hecho a la migración 052: create table if not exists, create index if not exists y un drop policy if exists antes de cada create policy, para que se pueda volver a correr entera sin importar en qué estado haya quedado.QuestionSummaryCard en vez de como formulario; "Editar" recién ahí abre QuestionForm precargado, con un "Cancelar" para volver al resumen sin guardar. saveChapterQuestion devuelve el id de la fila (.select('id').single() después del insert o el update): hace falta para poder editar o borrar una pregunta recién creada en la misma sesión, antes de que la pantalla se haya vuelto a cargar con ese dato por props.unique(chapter_id) de la v1 no dejaba tener una encuesta Y una trivia en el mismo capítulo. No era solo cosa de la UI: el propio esquema impedía más de una fila por capítulo, y saveChapterQuestion hacía upsert sobre chapter_id (cada alta nueva pisaba la anterior). Arreglado de punta a punta con la migración 054 y la reescritura de ChapterQuestionsManager/ChapterPath descriptas arriba.getChaptersWithQuestions (clubDetail.js) trae, de una sola vez, dos listas: en qué capítulos hay algo sin responder para quien mira la pantalla, y en cuáles ya respondió TODO. ChapterPath dibuja el mismo circulito sobre CUALQUIER nodo en alguna de las dos listas (pasado, actual o todavía no alcanzado) — mismo lenguaje visual que ya usa la insignia de racha (🔥), en la esquina opuesta — pero sólido mientras falte responder algo y apagado (solo el borde) una vez que ya respondiste todo; en los dos casos es tocable (openQuestionsFor), abre TODAS las preguntas del capítulo (no solo las pendientes) sin marcarlo como el actual — a diferencia del salto automático de handleTap, que solo pasa por acá cuando queda algo sin responder. Se armaron varios mockups antes de elegir esta forma: primero tres maneras de destacarla (distintivo en el nodo, anillo dorado, chip debajo), después una vista panorámica de Tu camino con comentarios/fotos/notas de voz/compañeros todos a la vez para ver si competían entre sí, y por último ocho formas distintas de "ordenar" el capítulo cuando se junta todo (reemplazar la pastilla de comentarios, combinar filas, una tarjeta de pendientes aparte, una raya al borde, etc.) — se eligió la más discreta de las ocho: no agrega ninguna fila nueva al camino, así que nunca compite con la pastilla de comentarios ni con el stack de compañeros.
getChapterQuestions ahora trae, para cada pregunta que ya contestaste, el agregado armado de entrada (results, con myOptionIndex/myBody para poder marcar tu propio voto o mostrar tu respuesta sin otro viaje al servidor) — la misma cuenta que antes solo devolvía answerChapterQuestion justo al responder. loadQuestionResults (clubs.js) quedó como una función interna compartida entre las dos, para no duplicar el conteo de poll/trivia ni el armado de "respuestas del club" de una pregunta abierta. ChapterQuestionModal arranca su estado (results/myPick/openBody) directo desde esos campos — el mismo truco del key por pregunta que ya hacía posible sembrar el estado inicial sin un useEffect, ahora también sirve para esto.googleMapsUrl()club.meeting_at existe, una insignia circular se superpone a la esquina de la portada (calendario si meeting_mode es 'lugar', cámara si es 'link') — sin fecha ni lugar, para no sumarle más texto a una tarjeta que ya tiene bastante. El detalle completo sigue viéndose solo al entrar al club, en el bloque de ClubScreen.jsx.<button> con el mismo preventDefault+stopPropagation que ya usa la fila de integrantes, así el toque no navega a la tarjeta entera.MeetingInfoModal (src/components/MeetingInfoModal.jsx), la misma hoja (Modal) que ya usan otros paneles cortos de la app: muestra el horario (formatMeetingDate()) y, para un lugar físico, la dirección aparte, como su propia fila tocable — tocar la dirección, no la hoja entera, es lo que abre Maps (googleMapsUrl()). Para una reunión por link, la fila equivalente es el link mismo, que abre directo (no hace falta un paso extra ahí — no hay nada más que mostrar).avatarLinknameLinkauthorReviewCard en ComentariosScreen.jsx — la reseña final que aparece arriba de todo al entrar a los comentarios de un club, con el mismo BookReviewCard+EngagementBlock que ya usa esta tarjeta en Inicio/Perfil (ver comentario en el código). El resto de ComentariosScreen (cada comentario de capítulo, cita o nota de voz, en el loop de visibleComments) ya tenía esta columna desde antes — no hizo falta tocarlo.gap parejo en vez del borde entre uno y el siguiente que ya usa ActivityCard, texto siempre completo (sin el recorte a 5 líneas ni el "más"), y la cita mostrada como bloque de Blockquote (fondo dorado propio, tipografía de display) en vez del texto itálico dorado del feed. CommentBody se reescribió como CommentRow (con su propio expanded/isTruncated, mismo patrón que ya usa ActivityCard con useRef+useEffect comparando scrollHeight/clientHeight) y ahora calca, punto por punto: avatar 30, padding: '12px 0' + borderBottom por fila (con un borderTop una sola vez, arriba de la lista — ni la reseña ni la lista de comentarios llevan ya un gap propio, la separación la da el borde de cada fila), nombre en negrita + fecha en gris, cita en texto itálico dorado entre comillas (Blockquote dejó de usarse acá) y el mismo recorte a TEXT_LINE_CLAMP = 5 líneas con "más" para cualquier cita o comentario largo (nota de voz queda afuera del recorte, el reproductor ya es compacto de por sí). ReviewCard recibió el mismo ajuste de fila/tipografía, para que las reseñas de arriba no desentonen con la lista de abajo.author solo se pasaba para la reseña final ("ya se sabe de quién es el perfil, no hace falta repetirlo" — ver el comentario original en ActivityCard.jsx), así que las tarjetas de foto, cita, comentario y nota de voz no tenían nombre ni avatar, y por lo tanto tampoco columna izquierda para alinear el resto — quedaban con el patrón viejo por falta de "author", no por un error en el layout nuevo. PerfilScreen.jsx ahora pasa author siempre (con los datos del perfil que se está mirando), en las cinco — mismo criterio que ya regía en Inicio.notifications_feed — un comentario scopeado a un repost ya no genera la notificación "te respondieron" para quien escribió el original (no es una respuesta a SU publicación). Queda pendiente, a propósito, avisarle a quien reposteó que le comentaron "lo que compartió" — no se pidió, y es un tipo de notificación nuevo de verdad, no un ajuste de este cambio; por ahora ese comentario no le avisa a nadie.recent_activityprofile_activityimage_urlphoto_urlnullundefinedonEditisOwnReviewCardLa notificación — sin tabla propia, como el resto de notifications_feed (migración 037), con una excepción. El resto de las notificaciones se arman al vuelo cruzando lo que YA existe (un like, una respuesta) — acá no hay nada que cruzar después: el borrado hace desaparecer la fila entera, sin dejar rastro. Se agrega moderation_deletes, una tabla mínima (a quién, en qué club/capítulo, de qué tipo, una vista previa corta del texto) que las tres acciones de borrar completan ANTES de borrar la fila real — es la única forma de tener algo de dónde armar el aviso después. notifications_feed suma una rama más (moderation_delete) que la lee, filtrada por target_profile_id = auth.uid() como todas las demás; describe() (src/lib/notifications.js) le agrega el texto: "Un administrador de {club} borró algo que publicaste por no ajustarse a las dinámicas del club".
Sin ningún otro cambio en la campana de notificaciones — mismo componente, mismo flujo de "visto" (notifications_seen_at), ninguna pantalla nueva: la nueva rama entra al mismo pozo que ya arma todo lo demás.
Bug: había que actualizar la página a mano para que lo borrado dejara de verse. revalidatePath('/', 'layout') (dentro de deleteComment/deleteQuote/deleteVoiceComment) sí refresca los props de la pantalla — pero no al toque, y mientras tanto el comentario recién borrado se seguía mostrando como si nada. Se agrega deletedIds (un Set en estado local de ComentariosScreen) que saca la fila de la vista apenas la acción del servidor confirma que se borró, sin esperar ningún round-trip extra — visibleComments y reviews ahora filtran también por !deletedIds.has(c.id). De paso, un toast de confirmación ("Comentario eliminado", "Cita eliminada", "Nota de voz eliminada", "Reseña eliminada") — mismo pill oscuro que ya usa ChapterPath para "Listo, vas por el Cap. N" — y un mensaje de error visible si el borrado llegara a fallar (antes el error se descartaba en silencio). ReviewCard perdió su propio useTransition/handleDelete interno (el pending que atenuaba la tarjeta mientras se borraba) — ahora el borrado y la confirmación los maneja ComentariosScreen, igual que ya hacía para comentarios/citas/notas de voz, así los cuatro tipos quedan con el mismo mecanismo.
is_current_bookclub_books.is_activetrueActivityCardfalseInicioScreen.jsxPerfilScreen.jsxactivitystartNewClubBook/StartNewBookModal/getActiveClubBook cambiaron de fondo — queda la narrativa de arriba como registro de cómo arrancó esto, pero para el comportamiento actual ver la migración 060.?libro=PastChapterPathrecent_activity/profile_activity suman club_book_id (migración 061) — el link de "Ver el resto de los comentarios en el club" (ActivityCard) armaba la URL sin ?libro=, así que cuando una publicación era de un libro en PARALELO (no el principal), el link igual aterrizaba en los comentarios del principal — otro libro. Con club_book_id disponible en cada fila del feed, el link ahora arma ?libro=<ese id> siempre, apuntando al libro correcto sea cual sea.is_active solo puede volver a false directo en la base. Con nada cerrándose solo, no hacía falta construir ese control para resolver lo que se pidió; se puede sumar más adelante si un club realmente quiere "guardar" un libro en vez de dejarlo en paralelo para siempre.