El mismo sistema visto desde 5 ángulos: cómo se reparte la lógica, dónde corre cada pieza, cómo llega un cambio a producción, cómo se levanta en tu máquina y cómo colaboran las piezas en una petición real. Debajo de cada diagrama está explicado cada componente.
Cómo se reparte la responsabilidad dentro de la aplicación, sin importar dónde corre. Es un monolito de Next.js en capas: la interfaz nunca habla directo con la base, siempre pasa por server actions o route handlers, que validan, chequean permisos y recién ahí usan Prisma.
Dónde corre cada pieza en producción. Todo vive en AWS: el tráfico de la app entra por un único servidor; los archivos pesados no pasan por él, van directo entre el navegador y S3/CloudFront.
El camino de un cambio desde tu branch hasta producción. Nada se despliega a mano: mergear a main dispara todo, y la versión vieja sigue atendiendo hasta que la nueva responde bien.
Lo mismo que producción, pero en tu máquina y con reemplazos locales: Postgres en Docker en vez de la base de producción y MailHog en vez de Resend. Hay dos formas de levantarlo.
Cómo colaboran las piezas en dos casos reales: abrir una página y subir una foto a la galería. El segundo muestra por qué los archivos van directo a S3 y no a través del servidor.
El sitio está hecho para gente súper nerd, apasionada por el software: estética de terminal, densidad antes que aire, un solo acento verde y el teclado como ciudadano de primera. Contamos cómo llegamos a esta estética, los principios que seguimos, los tokens y cada componente renderizado en vivo con todos sus estados.
~/desarrollo/diseno →También usamos shadcn/ui para los componentes de interfaz. Leé cómo usamos cada una ↓
Charlamos el desarrollo del sitio en un grupo de WhatsApp ↗. No hace falta que vayas a programar: podés sumarte a leer lo que hablamos si te sirve, o preguntar lo que quieras.
Por qué el sitio está hecho como está: cada ADR cuenta el problema, lo que se decidió, lo que cuesta y las alternativas que se descartaron. Si querés cambiar una de estas decisiones, abrí un issue proponiendo un ADR nuevo que la reemplace.
cached()), y las escrituras son Server Actions en src/actions. Solo hay route handlers donde hace falta una URL pública: búsqueda, feed RSS, imágenes de OpenGraph, notificaciones y embeds.src/lib/server-action-auth.test.ts.@prisma/adapter-pg (o $queryRaw con template tags), nunca con SQL armado a mano. Las migraciones son SQL versionado en prisma/migrations y el deploy aplica las pendientes.src/lib/sql-safety.test.ts impiden SQL concatenado (OWASP A03).Dockerfile.prod) en GitHub Actions y Kamal la despliega en un EC2 sin downtime. CloudFront queda delante como CDN: cachea /_next/static y las imágenes, y llega a la app como origin.programaconnosotros.com.visibility: hidden cuando termina la animación: el navegador deja de pintarlas y componerlas.md:, lg:): dentro de una ventana angosta en una pantalla ancha se verían rotas. Habría que migrar todo a container queries.usePathname son globales al documento: varias páginas a la vez se pisarían. Habría que aislar router, portales y scroll por ventana.cached() (src/lib/cache.ts), que declara qué tablas lee. Una extensión de Prisma vence esos tags en cada escritura a esas tablas, así nadie invalida a mano. No se cachean URLs firmadas ni lo que depende de la hora actual.[cache] <name> reads <Model>.src/data, conversaciones…). La base guarda lo que generan los usuarios y referencia el contenido del repo por id, sin clave foránea (ContentMark). Desde octubre de 2026 las recomendaciones (artículos, libros, cursos y videos) son la excepción: cualquier miembro las propone, así que viven en la tabla Recommendation con revisión de admins en /admin/recomendaciones, y las listas del repo se cargaron con una migración de datos que mantuvo sus ids.cached().pnpm test:db corre la integración contra un Postgres local descartable y pnpm test:e2e (Playwright) se corre a mano. GitHub Actions solo construye y despliega.--no-verify) deja pasar código roto: la revisión del PR lo tiene que notar.PostgreSQL con Prisma: 46 modelos y 68 relaciones, definidos en prisma/schema.prisma. Estas son las decisiones que dan forma al esquema, y abajo el diagrama completo de entidades y relaciones.
$ pnpm db:diagram # regenera el diagrama desde el schema · última actualización: 9 de octubre de 2026
Una guía para aprender con este proyecto: qué es cada tecnología, los conceptos que tenés que conocer y cómo la usamos acá, con fragmentos reales del código. Al final, cómo funcionan por dentro módulos del sitio como la galería y los eventos. Tocá el nombre del archivo de cada ejemplo para leerlo completo en GitHub.
Next.js es un framework de React que suma lo que React solo no trae: ruteo, renderizado en el servidor, caché, optimización de imágenes y fuentes, y un build listo para producción. El App Router (la carpeta app/) arma las rutas a partir de carpetas: cada carpeta es un segmento de la URL y ciertos archivos con nombre especial definen qué se renderiza en ese segmento.
page.tsx la carpeta existe pero no se puede visitar.<Suspense> automático.(platform)/desarrollo responde en /desarrollo.consejos/[id] atiende /consejos/abc123 y recibe { id: "abc123" } en params.sitemap.ts, robots.ts y opengraph-image.tsx generan /sitemap.xml, /robots.txt y la imagen que se ve al compartir el link.Todo el sitio vive en src/app. Las secciones de la comunidad están dentro del grupo (platform), que comparte el layout con sidebar, navegación mobile y PCN OS. Las pantallas de login y registro están en autenticacion/, que tiene su propio layout más simple.
Casi todas las páginas exportan metadata (o generateMetadata si dependen de la base) para el título, la descripción y las tarjetas de Open Graph, y muchas tienen su opengraph-image.tsx que dibuja una tarjeta con estética de terminal.
Un recorte del árbol de rutas y qué URL genera cada archivo.
src/app/
├── layout.tsx # layout raíz: <html>, fuentes, providers
├── (platform)/ # grupo: comparte sidebar, no aparece en la URL
│ ├── layout.tsx
│ ├── desarrollo/
│ │ ├── page.tsx # → /desarrollo
│ │ ├── loading.tsx # skeleton mientras carga
│ │ └── opengraph-image.tsx
│ └── consejos/[id]/
│ └── page.tsx # → /consejos/:id
├── autenticacion/ # → /autenticacion/iniciar-sesion, /registro…
├── api/search/route.ts # → GET /api/search?q=…
├── sitemap.ts # → /sitemap.xml
└── robots.ts # → /robots.txtLa imagen para redes de cada consejo se genera con los datos del consejo. params es una Promise en Next 15+.
export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE;
export const alt = 'Consejo de la comunidad programaConNosotros';
export default async function Image({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const advice = await prisma.advice.findUnique({
where: { id },
select: { content: true, author: { select: { name: true } } },
});
return renderTerminalCard({
path: 'consejos',
command: advice ? `fortune --from "${advice.author.name}"` : 'fortune',
title: advice ? `“${advice.content}”` : 'Consejos de la comunidad',
meta: advice ? [`@${advice.author.name}`] : [],
});
}En el App Router todo componente es, por defecto, un Server Component: se ejecuta en el servidor, puede ser async, puede leer la base de datos o secretos directamente, y al navegador le llega solo el HTML resultante (su código no se suma al bundle de JavaScript). Cuando necesitás estado, efectos o eventos del navegador, marcás el archivo con "use client" y pasa a ser un Client Component.
await adentro del cuerpo: no hace falta useEffect ni un endpoint intermedio para traer datos.cookies(), headers() y params son asíncronas desde Next 15: siempre van con await.Las páginas leen la base con Prisma directamente desde el componente. Por ejemplo, la página de un consejo busca el consejo y su autor sin pasar por una API.
Los componentes interactivos (formularios, el buscador, PCN OS, el cursor hacker) son Client Components, y las páginas los usan como hojas del árbol.
generateMetadata corre en el servidor, espera params y consulta la base para armar el título de la pestaña.
export async function generateMetadata(props: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const params = await props.params;
const advice = await prisma.advice.findUnique({
where: { id: params.id },
select: {
content: true,
author: {
select: {
name: true,
},
},
},
});
// …
}
export default async function AdvicePage(props: { params: Promise<{ id: string }> }) {
const params = await props.params;
const sessionId = (await cookies()).get('sessionId');
// …
}Con streaming, el servidor manda el HTML a medida que lo tiene listo en vez de esperar a que termine todo. <Suspense> marca una parte que puede tardar: mientras se resuelve se muestra el fallback y, cuando los datos llegan, React reemplaza el skeleton por el contenido sin recargar.
fetch(url, { next: { revalidate: 3600 } }) guarda la respuesta y la vuelve a pedir como mucho una vez por hora.Esta misma página es el ejemplo: las estadísticas de colaboración piden datos a la API de GitHub, que puede tardar, así que van dentro de un <Suspense> con un skeleton. El resto de /desarrollo se muestra sin esperar.
Cada sección tiene además su loading.tsx con skeletons que imitan la forma real de la página.
<Section title="Estadísticas de colaboración">
<Suspense fallback={<CollaborationStatsSkeleton />}>
<CollaborationStats />
</Suspense>
</Section>export default function Loading() {
return (
<>
<div className="flex flex-1 flex-col p-4 pt-0">
<div className="mt-4">
<PageTitleSkeleton titleClassName="w-44" />
<TextLineSkeleton lineClassName="h-5" className="h-3.5 w-52" />
</div>
</div>
</>
);
}Una Server Action es una función async en un archivo marcado con "use server". Desde un componente del cliente la llamás como cualquier función, pero Next la ejecuta en el servidor a través de un POST que arma solo. Reemplazan a la mayoría de los endpoints REST para mutaciones: crear, editar, borrar.
Las acciones viven en src/actions, agrupadas por dominio (auth, events, testimonials, talks…), un archivo por acción y su test al lado.
Todas siguen el mismo patrón: límite de envíos, validación con Zod, sesión desde la cookie, escritura con Prisma y revalidatePath de las páginas afectadas.
'use server';
import prisma from '@/lib/prisma';
import { testimonialSchema, TestimonialFormData } from '@/schemas/testimonial-schema';
import { revalidatePath } from 'next/cache';
import { cookies } from 'next/headers';
// …
export const createTestimonial = async (data: TestimonialFormData) => {
await enforceRateLimit('createContent');
const validatedData = testimonialSchema.parse(data);
const sessionId = (await cookies()).get('sessionId')?.value;
if (!sessionId) {
throw new Error('Debes estar autenticado para crear un testimonio');
}
// …
const testimonial = await prisma.testimonial.create({
data: {
body: validatedData.body,
userId: session.userId,
},
});
// …
revalidatePath('/testimonios');
};Un route.ts dentro de app/ define un endpoint HTTP: exportás funciones con el nombre del método (GET, POST…) que reciben un Request y devuelven un Response. El proxy.ts (antes llamado middleware.ts, renombrado en Next 16) corre antes de cada request que matchee su matcher y puede redirigir, reescribir o tocar headers.
El buscador (⌘K) consulta /api/search, que mezcla un índice estático de las páginas con eventos, charlas, consejos y proyectos de la base.
El proxy está preparado para /perfil, pero hoy deja pasar todo: los permisos se verifican en cada página y cada server action.
export async function GET(request: NextRequest) {
const query = (request.nextUrl.searchParams.get('q') ?? '').trim().slice(0, MAX_QUERY_LENGTH);
if (!query) {
return Response.json({ query, results: [] } satisfies SearchResponse);
}
let databaseEntries: Awaited<ReturnType<typeof loadDatabaseEntries>> = [];
try {
databaseEntries = await loadDatabaseEntries(query);
} catch (error) {
// Static content is still searchable when the database is unavailable.
console.error('search: database lookup failed', error);
// …
}export function proxy(_request: NextRequest) {
// …
return NextResponse.next();
}
export const config = {
matcher: ['/perfil/:path*'],
};React es una librería para construir interfaces con componentes: funciones que reciben props y devuelven JSX. Cuando cambia el estado, React vuelve a ejecutar el componente y actualiza en el DOM solo lo que cambió. Los hooks (useState, useEffect, useMemo…) son la forma de darle estado y efectos a esas funciones.
use… que solo se llaman en el nivel superior de un componente o de otro hook, nunca dentro de ifs o loops.useContentMarks).Los componentes están en src/components, agrupados por feature (events/, talks/, os/…). Los hooks propios están en src/hooks.
El layout raíz monta los providers globales una sola vez. Como necesitan estado del navegador, cada provider es un Client Component chico que recibe children.
El useState asegura que el QueryClient se cree una sola vez por pestaña y no en cada render.
'use client';
import { useState, type PropsWithChildren } from 'react';
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
export const ReactQueryProvider = ({ children }: PropsWithChildren) => {
const [client] = useState(new QueryClient());
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};TypeScript agrega tipos estáticos a JavaScript. El compilador revisa que uses bien cada valor (que no llames a algo que puede ser null, que no le pases un string a algo que espera un número) y después borra los tipos: en runtime es JavaScript normal. El editor usa esa información para autocompletar y refactorizar.
z.infer, ReturnType, Awaited y keyof typeof sacan tipos de código que ya existe, así hay una sola fuente de verdad.Todo el proyecto es TypeScript en modo estricto. Prisma genera los tipos de cada modelo y Zod los de cada formulario, así que casi nunca escribimos tipos a mano para los datos.
Antes de pushear, pnpm build corre el chequeo de tipos: si no compila, no se pushea.
El resultado es una unión discriminada por success y error: quien llama sabe que email solo existe en el caso EMAIL_NOT_VERIFIED.
export const signIn = async (
data: z.infer<typeof formSchema>,
): Promise<
| { success: true; redirectTo: string }
| { success: false; error: 'INVALID_CREDENTIALS' }
| { success: false; error: 'EMAIL_NOT_VERIFIED'; email: string }
> => {satisfies chequea la forma de cada regla y keyof typeof convierte las claves en un tipo: enforceRateLimit("typo") no compila.
export const RATE_LIMITS = {
signIn: { limit: 20, windowSeconds: 15 * 60 },
signUp: { limit: 5, windowSeconds: 60 * 60 },
// …
photoDownload: { limit: 30, windowSeconds: 60 * 60 },
} satisfies Record<string, RateLimitRule>;
export type RateLimitName = keyof typeof RATE_LIMITS;Tailwind es un framework de CSS "utility-first": en vez de escribir hojas de estilo con clases semánticas, componés el diseño con clases chicas que hacen una sola cosa (p-4, font-mono, border-b). En el build escanea el código y genera solo el CSS de las clases que usás. Los variants (hover:, md:, dark:) aplican una clase bajo una condición.
@theme de src/app/globals.css y se usan como clases (text-pcnGreen, border-pcnGreen-200).@custom-variant en el CSS podés crear tus propios prefijos condicionales.text-[11px], pb-[env(safe-area-inset-bottom)].clsx arma la lista de clases condicionales y tailwind-merge resuelve conflictos (si pasás p-2 y p-4, gana la última).La paleta pcnGreen es el verde fósforo del sitio con 9 niveles de opacidad. Las líneas finas de las grillas son border-pcnGreen-200.
Definimos dos variants propios para PCN OS: os: aplica en pantallas grandes cuando la página es el escritorio, y embedded: cuando la página está dentro de una ventana de PCN OS (un iframe). Un script en el layout raíz marca data-embedded en <html> antes del primer paint para que no haya parpadeo.
/*
PCN OS variants:
- `os:` applies on large screens when the page is the desktop host (not inside a window).
- `embedded:` applies when the page is rendered inside a PCN OS window (an iframe).
- `lite:` applies in PCN OS liviano (see src/components/os/os-display-mode.ts).
The `data-embedded` and `data-os-mode` attributes are set before paint by the scripts in the
root layout; `data-os-mode="classic"` turns the desktop off, so `os:` excludes it.
*/
@custom-variant os {
@media (width >= 1024px) {
html:not([data-embedded]):not([data-os-mode='classic']) & {
@slot;
}
}
}
@custom-variant embedded (html[data-embedded] &);
@custom-variant lite (html[data-os-mode='lite'] &);La barra de navegación mobile se oculta en desktop (md:hidden) y dentro de una ventana de PCN OS (embedded:hidden).
className="mobile-tab-bar pointer-events-auto fixed inset-x-0 bottom-0 z-[60] bg-transparent pb-[env(safe-area-inset-bottom)] embedded:hidden md:hidden"La grilla "con reglas" del sitio: celdas que comparten líneas finas en vez de flotar como tarjetas.
// A grid whose cells share hairlines instead of floating as separate cards:
// the grid draws the top/left edge and every cell draws its own bottom/right.
export const RuledGrid = ({ className, ...props }: HTMLAttributes<HTMLDivElement>) => (
<div className={cn('grid border-l border-t border-pcnGreen-200', className)} {...props} />
);shadcn/ui no es una dependencia: es una colección de componentes que copiás a tu repo y modificás a gusto. Por debajo usan Radix UI, primitivas sin estilos que resuelven la parte difícil (foco, teclado, ARIA, portales) de diálogos, menús, tabs o selects. Los estilos los ponés vos con Tailwind.
class-variance-authority define variantes (variant, size) como un mapa de clases, con tipos generados para las props.<Button asChild> le pase sus estilos al hijo, por ejemplo un <Link>, sin anidar un botón dentro de un link.Los componentes están en src/components/ui y fueron rediseñados con la estética de terminal: botones con texto en mono que se leen como llamadas a funciones (abrirGitHub();), tabs, diálogos y menús con bordes en verde fósforo.
// Buttons with a fill and a border label their action as a function call, e.g. `crearEvento();`.
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm font-mono text-sm font-medium …',
{
variants: {
variant: {
default: primaryCta,
destructive:
'border border-red-500/60 bg-red-500/10 text-red-400 hover:bg-red-500/20 …',
outline: secondaryCta,
ghost: 'text-foreground/80 hover:bg-pcnGreen-100 hover:text-pcnGreen',
link: 'text-pcnGreen underline-offset-4 hover:underline',
pcn: primaryCta,
// …
},
},
},
);Zod describe la forma de los datos con un schema y lo valida en runtime: schema.parse(x) devuelve el dato tipado o lanza un error con mensajes por campo. React Hook Form maneja el estado de los formularios sin re-renderizar todo en cada tecla; con zodResolver usa el schema de Zod para validar.
z.infer, también el tipo TypeScript.useForm para validar y mostrar los errores por campo.Los schemas viven en src/schemas y se importan desde los dos lados: el formulario los usa con zodResolver y la server action hace parse con el mismo schema.
import { z } from 'zod';
export const testimonialSchema = z.object({
body: z.string().min(10, { message: 'El testimonio debe tener al menos 10 caracteres' }),
});
export type TestimonialFormData = z.infer<typeof testimonialSchema>;Un formulario con una lista dinámica de oradores: useFieldArray agrega y saca filas.
const form = useForm<TalkProposalFormData>({
resolver: zodResolver(talkProposalSchema),
defaultValues: {
title: '',
description: '',
speakers: [defaults.firstSpeaker],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'speakers',
});TanStack Query (React Query) cachea en el cliente datos que vienen del servidor. Cada consulta tiene una queryKey; la librería deduplica pedidos, los reintenta, los refresca cuando quedan viejos y permite actualizaciones optimistas: mostrar el cambio antes de que el servidor confirme y deshacerlo si falla.
staleTime define cuánto tiempo el dato se considera fresco.onMutate, onError, onSettled.onMutate guardás el estado anterior y escribís el nuevo en la caché; en onError lo restaurás.Lo usamos donde la UI tiene que responder al instante, como marcar un artículo como leído o un video como visto. Las funciones de la query son server actions, así que no hay endpoints intermedios.
const { data, isLoading } = useQuery({
queryKey,
queryFn: () => getContentMarks(contentType),
staleTime: 60 * 1000,
});
// …
const mutation = useMutation({
mutationFn: async (changes: SetMarkInput[]) => { /* … */ },
onMutate: async (changes) => {
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<MarksData>(queryKey);
queryClient.setQueryData<MarksData>(queryKey, (current) => { /* … */ });
return { previous };
},
onError: (_error, _changes, context) => {
queryClient.setQueryData(queryKey, context?.previous);
toast.error('No pudimos guardar el cambio. Probá de nuevo.');
},
onSettled: () => queryClient.invalidateQueries({ queryKey }),
});TanStack Table es una librería "headless" para tablas: no dibuja nada, solo calcula. Le pasás los datos y la definición de columnas y te devuelve filas ya filtradas, ordenadas y paginadas; el HTML y los estilos los ponés vos. Es la misma idea que Radix, pero para tablas.
accessorKey), cómo se dibuja su encabezado y su celda, y si se puede ordenar.getSortedRowModel, getFilteredRowModel, getPaginationRowModel. Lo que no usás no pesa.useState, así los conectás con otros componentes como un buscador.La usamos en la lista de inscripciones de cada evento y en la tabla de /usuarios. El buscador global es el mismo SearchBar estilo grep del resto del sitio, conectado al globalFilter de la tabla; las filas canceladas se ven atenuadas.
const [sorting, setSorting] = useState<SortingState>([]);
const [globalFilter, setGlobalFilter] = useState('');
const table = useReactTable({
data,
columns: registrationColumns,
state: { sorting, globalFilter },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 100 } },
});export const registrationColumns: ColumnDef<EventRegistrationRow>[] = [
{
accessorKey: 'name',
meta: { className: 'min-w-[260px] whitespace-nowrap' },
header: ({ column }) => (
<SortableHeader
label="Nombre"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
/>
),
// …Un Date de JavaScript es un instante (milisegundos desde 1970 en UTC); la zona horaria aparece recién al mostrarlo. Intl.DateTimeFormat, que viene con el navegador y con Node, formatea un instante en un idioma y una zona dados. date-fns es una librería de funciones chicas e inmutables para formatear y calcular con fechas, con traducciones como es.
suppressHydrationWarning acepta esa diferencia puntual.format, formatDistanceToNow), no toda la librería.Las fechas de los eventos se muestran con LocalDate, LocalTime y LocalDateTime: en el servidor se formatean en la hora de Buenos Aires y en el navegador en la zona de quien visita, en formato 24 h. date-fns se usa para textos más armados, como "hace 3 días" en los anuncios o el horario del flyer.
const CANONICAL_TZ = 'America/Argentina/Buenos_Aires';
function tz(): string | undefined {
return typeof window === 'undefined' ? CANONICAL_TZ : undefined;
}
export function LocalTime({ date }: { date: Date | string }) {
const d = new Date(date);
const formatted = new Intl.DateTimeFormat('es-AR', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: tz(),
}).format(d);
return (
<time dateTime={d.toISOString()} suppressHydrationWarning>
{formatted}
</time>
);
}{formatDistanceToNow(new Date(announcement.createdAt), {
addSuffix: true,
locale: es,
})}Motion (antes Framer Motion) anima componentes de React de forma declarativa: le decís el estado inicial, el final y el de salida, y la librería interpola. Su AnimatePresence resuelve algo que React solo no puede: animar un componente mientras se desmonta. Embla es un motor de carruseles liviano, con arrastre táctil y snap, sobre el que shadcn/ui arma su Carousel.
motion.div: cómo aparece, cómo queda y cómo se va.exit.selectedScrollSnap) para saber cuál se ve.Motion se importa desde motion/react y anima detalles de la interfaz: el botón de volver arriba, el indicador de scroll, las ventanas y el dock de PCN OS, y los contadores que suben (NumberTicker). La home no lo usa: su hero y sus apariciones al scrollear son animaciones CSS, para que se vean sin esperar a que cargue el JavaScript. Embla mueve el carrusel de flyers de cada evento y los de charlas y lightning talks.
<motion.div
initial={{ opacity: 0, scale: 0.85, filter: 'blur(4px)' }}
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
exit={{ opacity: 0, scale: 0.85, filter: 'blur(4px)' }}
transition={{ duration: 0.18, ease: 'easeOut' }}
// …
>Sin AnimatePresence, el botón desaparecería de golpe al volver arriba.
<AnimatePresence>
{isVisible && (
<ScrollHudButton
onClick={scrollToTop}
label="Volver arriba"
code={String(Math.round(progress * 100)).padStart(2, '0')}
progress={progress}
icon={<ChevronsUp className="h-4 w-4" strokeWidth={2.25} />}
/>
)}
</AnimatePresence>const [api, setApi] = React.useState<CarouselApi>();
const [current, setCurrent] = React.useState(0);
React.useEffect(() => {
if (!api) return;
setCurrent(api.selectedScrollSnap());
const handleSelect = () => setCurrent(api.selectedScrollSnap());
api.on('select', handleSelect);
return () => {
api.off('select', handleSelect);
};
}, [api]);next/font descarga las fuentes en el build y las sirve desde tu propio dominio, sin pedidos a terceros ni saltos de layout cuando cargan. next-themes maneja el tema (claro/oscuro) poniendo una clase en <html> antes de que se pinte la página. Sonner es una librería de toasts: llamás a toast.success() desde cualquier lado y el <Toaster> montado en el layout los muestra.
GeistSans.variable expone la fuente como variable CSS, que Tailwind usa en font-sans y font-mono.toast() funciona desde un handler, después de una server action.El layout raíz carga Geist Sans y Geist Mono, fuerza el tema oscuro y monta el Toaster, al que le dimos estética de PCN OS: panel con scanlines, borde iluminado del color del toast y un prompt > antes del título. Los formularios y acciones avisan el resultado con toast.success o toast.error.
<html
lang="es"
className={`${GeistSans.variable} ${GeistMono.variable}`}
suppressHydrationWarning
>
{/* … */}
<body className={GeistSans.className}>
<ThemeProvider
attribute="class"
defaultTheme="dark"
forcedTheme="dark"
disableTransitionOnChange
>
<ReactQueryProvider>{children}</ReactQueryProvider>
<Toaster closeButton position="top-right" />
{/* … */}
</ThemeProvider>
</body>
</html>try {
await markNotificationAsRead(notificationId);
toast.success('Notificación marcada como leída');
router.refresh();
} catch (error: any) {
toast.error(error.message || 'Error al marcar la notificación como leída');
}Prisma es un ORM para Node.js. Describís los modelos en schema.prisma, Prisma genera un cliente con tipos para cada tabla y relación, y Prisma Migrate convierte los cambios del schema en archivos SQL versionados que se aplican en orden en cada base.
prisma.user.findUnique({ where, select, include }): consultas tipadas, con autocompletado de campos y relaciones. Se genera como código TypeScript del proyecto en src/generated/prisma (no se commitea).pg, el driver de Postgres de Node, a través de @prisma/adapter-pg. prisma.config.ts define la URL que usa el CLI para migrar.prisma/migrations con su migration.sql. En producción se aplican con prisma migrate deploy.globalThis evita abrir una conexión nueva en cada recarga.El schema tiene decenas de modelos (usuarios, sesiones, eventos, charlas, galería, proyectos…) y más de 70 migraciones desde 2024. El cliente se regenera solo en postinstall y prebuild.
Para un cambio de schema: editás schema.prisma, creás la migración con pnpm create-migration <nombre>, revisás el SQL generado y lo commiteás junto con el cambio.
model Testimonial {
id String @id @default(cuid())
body String
userId String // Usuario que creó el testimonio (requerido)
featured Boolean @default(false) // Si aparece en la home page
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([createdAt])
@@index([featured])
}-- AlterTable
ALTER TABLE "User" ADD COLUMN "instagramUrl" TEXT;import { PrismaClient } from '@/generated/prisma/client';
import { pgAdapter } from '@/lib/database-url';
const prismaClientSingleton = () => {
return new PrismaClient({ adapter: pgAdapter(process.env.DATABASE_URL) });
};
declare const globalThis: {
prismaGlobal: ReturnType<typeof prismaClientSingleton>;
} & typeof global;
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton();
export default prisma;
if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma;Un cache de datos guarda el resultado de una consulta para no repetirla en cada request. Lo difícil no es guardar sino invalidar: saber cuándo lo guardado dejó de ser cierto. Next.js trae un data cache del lado del servidor (unstable_cache) donde cada entrada lleva tags, y revalidateTag vence todas las entradas de un tag de una vez.
.next/cache hasta que vence o se invalida.revalidateTag(tag, { expire: 0 }) vence todas las que la llevan y el próximo request las recalcula.db:Event); escribir en una tabla vence su tag. Más grueso que invalidar fila por fila, pero no hay forma de olvidarse un caso.Casi todo lo que el sitio muestra es igual para todos y cambia poco, así que las lecturas públicas pasan por cached() (src/lib/cache.ts): eventos, charlas, galería, consejos, proyectos, miembros, perfiles, logros, el feed, la búsqueda, el sitemap. Una página vista por alguien sin sesión no toca Postgres; con sesión, solo la busca a ella y lo propio (tu inscripción, tus likes).
Nadie invalida a mano: el cliente de Prisma tiene una extensión que, después de cada escritura, calcula qué tablas tocó (incluidas las escrituras anidadas y lo que se borra en cascada, a partir de las relaciones del schema) y vence sus tags. Una server action nueva no tiene que acordarse de nada.
Al cachear una lectura nueva hay que declarar todas las tablas que lee, incluidas las de los include y los filtros por relación. En desarrollo, si una lectura cacheada toca una tabla que no declaró, la consola avisa con [cache] <nombre> reads <Tabla>.
Las URLs firmadas de la galería vencen, así que se cachean las filas sin firmar y se firman después; cada firma se guarda durante su hora. Las tablas de tracking, logs, sesiones y tokens no se cachean nunca.
Una lectura cacheada: un nombre, la consulta y las tablas que lee.
export const listEventIndex = cached(
'event-index',
() =>
prisma.event.findMany({
where: { deletedAt: null },
orderBy: { date: 'asc' },
select: { id: true, name: true, date: true, endDate: true },
}),
{ models: ['Event'] },
);Lo que depende de la hora se filtra sobre la lista cacheada.
export const fetchUpcomingEvents = async (limit: number = 5) => {
const now = new Date();
const events = await listEventIndex();
return events
.filter(({ date, endDate }) => date >= now || (endDate !== null && endDate >= now))
.slice(0, limit)
.map(({ id, name, date }) => ({ id, name, date }));
};Cada escritura vence las lecturas de las tablas que tocó.
}).$extends({
name: 'data-cache',
query: {
$allModels: {
async $allOperations({ model, operation, args, query }) {
if (!WRITES.has(operation)) {
if (process.env.NODE_ENV !== 'production') checkCachedRead(modelsIn(model, args));
return query(args);
}
const result = await query(args);
expireModels(modelsIn(model, args, isDelete(operation)));
return result;
},
},
},
});PostgreSQL es una base de datos relacional open-source: tablas con filas y columnas tipadas, relaciones con claves foráneas, transacciones ACID e índices para que las consultas no recorran la tabla entera. Se consulta con SQL; en este proyecto casi siempre a través de Prisma.
onDelete: Cascade en Prisma se traduce a una FK que borra las filas hijas al borrar la madre.@@index([createdAt])) a cambio de un poco más de costo al escribir.mode: "insensitive" en Prisma usa ILIKE de Postgres para buscar ignorando mayúsculas.En local Postgres corre en Docker Compose. Cada git worktree tiene además su propia base aislada, creada por scripts/setup-worktree-db.sh, así podés probar migraciones en una branch sin romper la de otra.
database:
image: postgres:13
container_name: pcn-db
restart: always
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
ports:
- '5432:5432'
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}']
interval: 5s
timeout: 5s
retries: 5const contains = { contains: query, mode: 'insensitive' as const };
const [events, talks, advice, projects] = await Promise.all([
prisma.event.findMany({
where: { deletedAt: null, OR: [{ name: contains }, { description: contains }] },
orderBy: { date: 'desc' },
take: DB_CANDIDATES,
// …
}),
// …
]);Autenticar es verificar quién sos; autorizar es decidir qué podés hacer. Un esquema clásico de sesiones: al loguearte, el servidor crea un registro de sesión y te manda su id en una cookie httpOnly (JavaScript no la puede leer). En cada request el servidor busca esa sesión para saber quién sos. Las contraseñas nunca se guardan en texto plano, sino como hash con un algoritmo lento como bcrypt.
sameSite: "lax" ayuda contra CSRF y secure la limita a HTTPS.No usamos un proveedor externo: el modelo Session vive en Postgres y la cookie se llama sessionId. Todas las lecturas pasan por findSession() (en src/lib/session.ts), que descarta las sesiones vencidas; cerrar sesión borra la fila. Registrarse requiere verificar el email con un código de 6 dígitos.
Todos los formularios pasan por enforceRateLimit, una ventana deslizante en memoria (alcanza porque el sitio corre en un solo proceso). Los admins no tienen límite.
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return { success: false, error: 'INVALID_CREDENTIALS' };
}
if (!user.emailVerified) {
return { success: false, error: 'EMAIL_NOT_VERIFIED', email };
}
await createSession(user.id);// La cookie lleva un token aleatorio; la base guarda solo su hash como id de la sesión
export const hashSessionToken = (token: string) =>
createHash('sha256').update(token).digest('hex');
// Todas las lecturas de sesión pasan por acá: una sesión vencida no sirve en ningún lado
export const findSession = (token: string) =>
prisma.session.findUnique({
where: { id: hashSessionToken(token), expires: { gt: new Date() } },
include: { user: true },
});
// Cerrar sesión borra la fila, así la cookie deja de servir aunque alguien la copie
export const deleteCurrentSession = async () => {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value;
if (token) {
await prisma.session.deleteMany({ where: { id: hashSessionToken(token) } });
}
cookieStore.delete(SESSION_COOKIE);
};S3 es el almacenamiento de objetos de AWS: guardás archivos (imágenes, videos) en un "bucket" bajo una clave. CloudFront es su CDN: copia esos archivos en servidores cerca de cada visitante. Una URL prefirmada es un permiso temporal firmado por el servidor para que el navegador suba o baje un archivo puntual sin conocer las credenciales.
next/image acepta optimizar imágenes remotas.Los flyers de eventos, las fotos de perfil, los logos de proyectos y la galería se guardan en S3 y se sirven por CloudFront.
Los archivos de la galería son privados: CloudFront solo los entrega con una URL firmada que generamos al renderizar. Cómo se optimizan las fotos y los videos antes de llegar ahí está en la nota de la galería, más abajo.
const uniqueFileName = `${folder}/${Date.now()}-${crypto.randomUUID()}.${extension}`;
const command = new PutObjectCommand({
Bucket: S3_BUCKET,
Key: uniqueFileName,
ContentType: contentType,
});
// URL válida por 5 minutos
// Firmar host y content-type para que el navegador pueda enviar el content-type correcto
const uploadUrl = await getSignedUrl(s3Client, command, {
expiresIn: 300,
signableHeaders: new Set(['host', 'content-type']),
});
return { uploadUrl, fileUrl: publicFileUrl(uniqueFileName), key: uniqueFileName };// CloudFront CDN for uploaded event flyers / photos
...(process.env.AWS_CLOUDFRONT_URL
? [{ protocol: 'https', hostname: new URL(process.env.AWS_CLOUDFRONT_URL).hostname }]
: []),Resend es un servicio para mandar emails desde código con una API: se verifica el dominio una vez (registros DNS) y cada email es una llamada con el remitente, el destinatario y el HTML. React Email permite escribir el contenido del email como un componente de React y convertirlo a HTML con render. MailHog es un servidor SMTP falso para desarrollo: acepta todos los emails y los muestra en una interfaz web, así podés probar flujos de verificación sin mandarle nada a nadie; en local Nodemailer le habla por SMTP.
Los códigos de verificación de cuenta y de recuperación de contraseña se mandan por email. Las plantillas son componentes en src/components/auth con estilos inline (los clientes de email ignoran casi todo el CSS externo) y @react-email/render las convierte a HTML. En producción salen por Resend; con Docker Compose, MailHog queda en http://localhost:18025 para ver lo que el sitio "envió".
const { error } = await new Resend(apiKey).emails.send({
from: formatSender(getSender()),
to,
subject,
html,
});
if (error) throw new Error(`Resend: ${error.message}`);// Enviar email con el código
const emailHtml = await render(EmailVerificationEmail({ userName: user.name, code }));
await sendEmail({
to: user.email,
subject: 'Verificá tu correo electrónico - Programa Con Nosotros',
html: emailHtml,
});GitHub expone una API REST pública para leer repos, commits, pull requests y contribuidores. Sin token permite 60 requests por hora por IP; con token, muchas más. Algunos endpoints de estadísticas se calculan en segundo plano y responden 202 hasta que el resultado está listo.
Link trae la URL de la última página, que sirve para contar sin bajar todo.La sección "Estadísticas de colaboración" de esta página, las contribuciones de cada perfil y /vinculos leen src/data/github-stats.json. Ese archivo lo genera pnpm github:stats (scripts/update-github-stats.mjs): estrellas, commits, PRs mergeadas, tiempo mediano hasta el merge, lenguajes, líneas de código y el detalle de cada contribuidor. El sitio nunca llama a GitHub mientras renderiza.
/** A /stats/* endpoint, or `null` if GitHub is still computing it after every retry. */
const githubStats = async (path) => {
for (let attempt = 1; attempt <= STATS_ATTEMPTS; attempt++) {
const response = await github(path);
if (response.status === 200) return response.json();
await new Promise((resolve) => setTimeout(resolve, STATS_RETRY_MS));
}
console.warn(`! ${path}: GitHub todavía lo está calculando, queda el valor anterior`);
return null;
};
/** Total item count of a paginated endpoint, read from the `rel="last"` page of `per_page=1`. */
const countFromLinkHeader = (response, fallback) => {
const match = response.headers.get('link')?.match(/[?&]page=(\d+)>; rel="last"/);
return match ? Number(match[1]) : fallback;
};Jest es un framework de testing para JavaScript: corre archivos *.test.ts, te da describe/it/expect y un sistema de mocks para reemplazar dependencias (la base, las cookies) por dobles controlados. Un test unitario prueba una unidad aislada: rápido y determinista.
jest-mock-extended crea un mock tipado de todo el cliente de Prisma, con cada método como jest.fn().Cada server action tiene su test al lado (sign-in.ts → sign-in.test.ts). jest.setup.ts mockea globalmente Prisma, next/headers, next/cache y next/navigation, así los tests no necesitan base ni servidor.
jest.mock('@/lib/prisma', () => {
const { mockDeep } = require('jest-mock-extended');
return {
__esModule: true,
default: mockDeep(),
};
});
// …
// redirect() normally throws to interrupt control flow; replicate that here so
// tests can assert on it with .rejects.toThrow('NEXT_REDIRECT:/some/path')
jest.mock('next/navigation', () => ({
redirect: jest.fn().mockImplementation((url: string) => {
throw new Error(`NEXT_REDIRECT:${url}`);
}),
}));it('returns INVALID_CREDENTIALS when password is wrong', async () => {
prismaMock.user.findUnique.mockResolvedValue(baseUser as any);
(bcryptMock.compare as jest.Mock).mockResolvedValue(false);
const result = await signIn(validInput);
expect(result).toEqual({ success: false, error: 'INVALID_CREDENTIALS' });
expect(prismaMock.session.create).not.toHaveBeenCalled();
});Playwright controla Chromium, Firefox y WebKit desde código. Sirve para tests end-to-end (abrir la página, hacer clic, verificar lo que se ve, como lo haría una persona) y para automatizar el navegador en general, por ejemplo sacar capturas.
page.getByRole(…) busca elementos como los ve un usuario (rol y texto) y no por clases CSS frágiles.La suite E2E de regresión está en tests/e2e/ y corre una vez por semana con pnpm test:e2e: recrea una base con datos de prueba, compila la app y la recorre en Chromium (desktop y mobile), con un test por caso de /desarrollo/calidad. Además usamos Playwright para las capturas de las PRs: pnpm screenshot /ruta abre cada ruta en Chromium y guarda la página completa.
test.describe('signed in as a member', () => {
test.use({ as: 'member' });
test('the session from auth.setup works', async ({ page, db }) => {
await page.goto('/notificaciones');
await expect(page).toHaveURL(/\/notificaciones/);
});
});const browser = await chromium.launch();
const context = await browser.newContext({
viewport: { width: 1280, height: 800 },
});
for (const route of routes) {
// …
const page = await context.newPage();
await page.goto(url, { waitUntil: 'networkidle' });
await page.screenshot({ path: outPath, fullPage: true });
await page.close();
}ESLint analiza el código buscando errores y malas prácticas (por ejemplo, romper las reglas de los hooks). Prettier lo formatea siempre igual, así nadie discute de comas. Husky instala git hooks: scripts que git corre antes de un commit o de un push. lint-staged corre comandos solo sobre los archivos que estás commiteando.
git commit. Acá: Prettier sobre los archivos staged.git push y lo cancela si algo falla: lint, formato, tests y build.Prettier usa prettier-plugin-tailwindcss, que además ordena las clases de Tailwind en un orden consistente.
pnpm lint
pnpm format:check
pnpm test
pnpm build"lint-staged": {
"*.{js,jsx,ts,tsx,json,jsonc,css,scss,md,mjs,cjs}": "prettier --write"
}Docker empaqueta una aplicación con todo lo que necesita (sistema, Node, dependencias) en una imagen; un contenedor es esa imagen corriendo, aislado del resto. Docker Compose levanta varios contenedores relacionados (web, base, mail) con un solo comando y los conecta en una red privada donde se encuentran por nombre.
docker-compose up -d levanta la web, Postgres y MailHog para desarrollar. En producción se construye Dockerfile.prod, que instala dependencias y hace el build de Next dentro de la imagen.
FROM node:24.21.0
WORKDIR /app
COPY . .
RUN npm install -g pnpm@9.4.0
RUN pnpm install \
&& pnpm run build
EXPOSE 3000
CMD ["pnpm", "start"]web:
build: .
container_name: pcn-web
restart: always
ports:
- '3000:3000'
volumes:
- .:/app
- /app/node_modules
depends_on:
database:
condition: service_healthy
mailhog:
condition: service_startedGitHub Actions ejecuta workflows (CI/CD) en máquinas de GitHub cuando pasa algo en el repo, por ejemplo un push. Kamal es una herramienta de deploy: construye la imagen Docker, la sube a un registry, se conecta por SSH a tus servidores y reemplaza el contenedor viejo por el nuevo sin downtime, con un proxy que maneja HTTPS.
Trabajamos en branches, las mergeamos a testing y de ahí a main. Cada push a main dispara el workflow: instala dependencias, aplica las migraciones pendientes con prisma migrate deploy y corre kamal deploy. El healthcheck que usa Kamal es la ruta /up.
on:
workflow_dispatch:
push:
branches:
- main
# …
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run Prisma Migrations
run: pnpm prisma migrate deploy
- name: Run Kamal deploy
run: kamal deployservice: pcn-website
# …
proxy:
host: programaconnosotros.com
ssl: true
app_port: 3000
# …
builder:
arch: amd64
context: .
dockerfile: Dockerfile.prod
cache:
type: ghapnpm es un package manager que guarda cada versión de cada paquete una sola vez en disco y la enlaza en cada proyecto: instala rápido y es estricto con las dependencias no declaradas. portless le da a cada servidor de desarrollo una URL estable https://<nombre>.localhost en vez de un puerto. Los git worktrees permiten tener varias branches del mismo repo en carpetas distintas al mismo tiempo.
pnpm-lock.yaml fija la versión exacta de cada dependencia; --frozen-lockfile falla si no coincide con package.json.pnpm dev corre Next a través de portless: el checkout principal responde en https://pcn-website.localhost y un worktree en la branch foo en https://foo.pcn-website.localhost. Cada worktree tiene su propia base de datos, creada automáticamente.
"dev": "portless run next dev",
"dev:docker": "next dev",
"setup-worktree-db": "bash scripts/setup-worktree-db.sh",# ── 4. Derive the per-worktree database name ─────────────────────────────────
WORKTREE_BASENAME=$(basename "$CUR")
# lowercase, replace non-alphanumeric runs with _, strip trailing underscores
DB_NAME="pcn_$(echo "$WORKTREE_BASENAME" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '_' | sed 's/_*$//')"Optimizar medios es achicar lo que baja cada visitante sin que se note: redimensionar al tamaño en que realmente se va a ver, recomprimir en formatos modernos (WebP para fotos, H.264 en MP4 para video), borrar metadatos que no hacen falta y servir una versión chica (thumbnail) en las grillas y la grande solo cuando alguien la abre. Una foto de celular pesa entre 3 y 10 MB y un minuto de video 4K cientos de MB; servidos tal cual, una grilla de fotos se vuelve inusable con datos móviles.
moov) al principio del archivo para que el video arranque mientras se sigue descargando.content-length-range para limitar el tamaño.Solo los admins suben contenido, desde /galeria/subir. Los archivos nunca pasan por nuestro servidor de ida: el navegador los manda directo a S3 con URLs prefirmadas, de a uno por vez, y después una server action termina el trabajo. Mientras sube, la página pide un wake lock para que el celular no apague la pantalla y avisa antes de cerrar la pestaña.
Fotos: en el navegador, exifr lee la fecha en que se sacó (DateTimeOriginal o CreateDate, y si no hay EXIF la fecha del archivo) para precompletar el formulario. Las fotos HEIC de iPhone se convierten a JPEG en el navegador con heic-to (libheif en un worker, cargado solo cuando hace falta), después de leer la fecha, porque sharp no decodifica HEIC. El original va a gallery/originals/ con un PUT prefirmado de 5 minutos; después createPhoto lo baja, optimizePhoto lo gira según el EXIF y genera dos WebP sin metadatos (GPS incluido): full de 2560 px a calidad 80 y thumb de 640 px a calidad 70. Se guardan bajo una carpeta con UUID, se borra el original y se crea el GalleryItem con el ancho y el alto.
Videos: antes de subirlo, readVideo carga el archivo en un <video> oculto para leer duración y medidas y captura un cuadro (al segundo 1, o a un décimo en clips cortos) en un canvas como portada JPEG de hasta 1280 px; si el navegador no puede, la portada es negra. Después compressVideo lo recodifica en el navegador con Mediabunny (WebCodecs): H.264 + AAC en MP4 con fast start, lado corto de hasta 1080 px, hasta 30 fps y 5 Mbps, más o menos 40 MB por minuto. Si el navegador no puede (sin VideoEncoder, o Firefox, que no codifica AAC y perdería el audio) o el resultado no es más chico, se sube el original.
El video se sube con un presigned POST de 15 minutos cuya policy rechaza más de 500 MB. createVideo verifica con un HEAD que el archivo llegó y convierte la portada a WebP (1280 px, calidad 75) con sharp. No hay ffmpeg ni transcodificación en el servidor: todo el trabajo pesado lo hace el navegador de quien sube.
Todo lo que está bajo gallery/ se guarda con Cache-Control: public, max-age=31536000, immutable (cada archivo nuevo es una clave nueva, así que nunca hay que invalidar caché) y CloudFront solo lo entrega con URL firmada. signGalleryItem firma al renderizar la página, con vencimiento al final de la hora siguiente: la misma foto tiene la misma URL durante toda una hora, así el navegador y la CDN la cachean.
En la UI, la grilla usa solo los thumbnails de 640 px con <img loading="lazy" decoding="async">; al pasar las 94 fotos viejas a thumbnails, la grilla pasó de ~16 MB a 3,3 MB. Cada foto o video tiene su página /galeria/[id] con la versión grande o un <video preload="metadata"> con la portada, que reproduce el MP4 progresivo y salta con range requests. Las flechas del teclado navegan y router.prefetch precalienta las vecinas. Los filtros por tipo, evento y persona viven en la URL, y la descarga del original pasa por /api/galeria/[id]/descargar, con rate limit de 30 por hora, que devuelve una URL prefirmada de S3 con Content-Disposition: attachment.
.rotate() sin argumentos aplica la orientación del EXIF; sharp no copia los metadatos a la salida salvo que se lo pidas, así que el GPS desaparece. clone() reusa la misma decodificación para las dos versiones.
export const FULL_SIZE = 2560;
export const THUMB_SIZE = 640;
const resize = { fit: 'inside', withoutEnlargement: true } as const;
export async function optimizePhoto(input: Buffer) {
const image = sharp(input, { failOn: 'none' }).rotate();
const [full, thumb] = await Promise.all([
image
.clone()
.resize({ width: FULL_SIZE, height: FULL_SIZE, ...resize })
.webp({ quality: 80 })
.toBuffer({ resolveWithObject: true }),
image
.clone()
.resize({ width: THUMB_SIZE, height: THUMB_SIZE, ...resize })
.webp({ quality: 70 })
.toBuffer(),
]);
return { full: full.data, thumb, width: full.info.width, height: full.info.height };
}La fecha de la foto sale del EXIF en el navegador. exifr se importa dinámicamente para no sumarlo al bundle de las páginas que no suben fotos.
export async function readTakenAt(file: File) {
if (!isVideo(file)) {
try {
const { default: exifr } = await import('exifr');
const exif = await exifr.parse(file, ['DateTimeOriginal', 'CreateDate']);
const date = exif?.DateTimeOriginal ?? exif?.CreateDate;
if (date instanceof Date && !Number.isNaN(date.getTime())) return date;
} catch {
// No EXIF: fall back to the file date.
}
}
return new Date(file.lastModified);
}La compresión de video corre en el navegador. Si la conversión perdiera una pista (audio o video) o el navegador no puede codificar H.264, devuelve null y se sube el original.
// Videos are re-encoded in the browser before uploading: H.264 (plays everywhere, unlike the
// HEVC iPhones record) with the short side capped at 1080p, at a bitrate that keeps a minute
// around 40 MB instead of the hundreds a 4K phone clip weighs.
const COMPRESSED_MAX_SHORT_SIDE = 1080;
// 60 fps phone clips come out at twice the bitrate; 30 is plenty for event videos.
const COMPRESSED_MAX_FRAME_RATE = 30;
const COMPRESSED_VIDEO_BITRATE = 5_000_000;
const COMPRESSED_AUDIO_BITRATE = 128_000;
// …
if (!(await canEncodeVideo('avc', { ...size, quality }))) return null;
const target = new BufferTarget();
const output = new Output({ format: new Mp4OutputFormat({ fastStart: 'in-memory' }), target });
const conversion = await Conversion.init({
input,
output,
tracks: 'primary',
video: {
codec: 'avc',
...size,
fit: 'contain',
frameRate,
quality,
// Bake the phone's rotation into the frames so every player shows it upright.
allowTransformationMetadata: false,
forceTranscode: true,
},
audio: { codec: 'aac', quality: new Quality({ bitrate: COMPRESSED_AUDIO_BITRATE }) },
showWarnings: false,
});
// Keep both picture and sound: Firefox, for one, can't encode AAC and would drop the audio.
const audioTrack = await input.getPrimaryAudioTrack();
const keepsAll = [track, audioTrack].every((t) => !t || conversion.utilizedTracks.includes(t));
if (!conversion.isValid || !keepsAll) return null;El flujo de un video: comprimir, subir con POST firmado, subir la portada y recién ahí crear el registro.
if (item.video) {
update(item.key, { status: 'compressing' });
const compressed = await compressVideo(item.file, (progress) =>
update(item.key, { progress }),
).catch(() => null);
const file = compressed?.file ?? item.file;
update(item.key, { status: 'uploading', progress: 0, uploadedSize: compressed?.file.size });
const { url, fields, key } = await getVideoUploadUrl(file.type, file.size);
await postFile(url, fields, file, (progress) => update(item.key, { progress }));
const poster = await getPhotoUploadUrl('poster.jpg', 'image/jpeg');
await putFile(poster.uploadUrl, item.video.poster, 'image/jpeg');
created = await createVideo(key, poster.key, {
// …Con un POST firmado es S3 el que rechaza un archivo de más de maxBytes, aunque alguien manipule el navegador.
export async function getPresignedPost(key: string, contentType: string, maxBytes: number) {
return createPresignedPost(s3Client, {
Bucket: S3_BUCKET,
Key: key,
Conditions: [
['content-length-range', 1, maxBytes],
['eq', '$Content-Type', contentType],
],
Fields: { 'Content-Type': contentType, 'Cache-Control': 'public, max-age=31536000, immutable' },
Expires: 15 * 60,
});
}El vencimiento se redondea a la hora para que la URL no cambie en cada render y se pueda cachear.
export function signGallerySrc(src: string, now = Date.now()) {
const expiresAt = new Date(Math.ceil(now / HOUR_MS) * HOUR_MS + HOUR_MS);
if (!isSignedGallerySrc(src)) return { url: src, expiresAt };
if (!KEY_PAIR_ID || !PRIVATE_KEY) throw new Error('Falta configurar la firma de CloudFront');
const url = getSignedUrl({
url: src,
keyPairId: KEY_PAIR_ID,
privateKey: PRIVATE_KEY,
dateLessThan: expiresAt.toISOString(),
});
return { url, expiresAt };
}
// …
export const signGalleryItem = <T extends { src: string; thumbSrc: string }>(item: T) => ({
...item,
thumbUrl: signGallerySrc(item.thumbSrc).url,
fullUrl: signGallerySrc(item.src).url,
});En la grilla solo se cargan los thumbnails, y recién cuando están por entrar en pantalla.
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={photo.thumbUrl}
alt=""
loading="lazy"
decoding="async"
className="h-full w-full object-cover object-top brightness-[0.8] saturate-[0.7] …"
/>Un módulo de eventos resuelve siempre lo mismo: publicar qué pasa, cuándo y dónde, decidir quién puede crearlo y gestionarlo, y ayudar a la gente a llegar (agendarlo, encontrar el lugar, inscribirse). La clave de diseño es cubrir muchas variantes con un solo modelo y campos opcionales en vez de tablas distintas por tipo de evento.
endDate nulo es un evento de un día; isOnline cambia la dirección por un link de streaming; capacity nulo es cupo ilimitado. La UI y la validación se adaptan a cada combinación.deletedAt: las inscripciones, charlas y fotos quedan, y todas las consultas filtran deletedAt: null.CLAVE:valor de hasta 75 bytes./cowork no apunta a un evento fijo sino al próximo que tenga ese slug, ideal para series que se repiten.El modelo Event cubre todas las variantes: presencial (con placeName, address, city, y un link de Google Maps opcional, googleMapsUrl, del que salen el mapa embebido y el link "abrir en Google Maps") u online (isOnline + streamingUrl); de un día o de varios (endDate); con cupo (capacity) o sin límite; con inscripción propia o externa (externalRegistrationUrl, por ejemplo Luma); con convocatoria de charlas (callForSpeakersEnabled); con un "cupo completo" manual (markedAsFull); con uno o varios flyers (flyerImages, un carrusel que se ordena al subirlo) y sponsors. El schema de Zod pide lugar, ciudad y dirección solo si el evento no es online.
Hay tres niveles de permisos: los admins pueden todo; los ambassadors (isAmbassador) crean eventos y editan o eliminan los que crearon; y cualquier usuario cargado como organizador puede editar el evento y gestionar sus inscripciones, charlas y propuestas. Quien crea un evento queda como organizador automáticamente, y elegir organizadores (con un buscador de miembros) queda para quien lo creó. Los eventos organizados aparecen en el perfil de cada persona.
Convocatoria de charlas: si está activa, el evento muestra "proponer →" y cualquiera con sesión manda una propuesta con título, descripción y uno o más speakers (precompletado con su perfil). Quienes gestionan el evento la aceptan o rechazan (PENDING, ACCEPTED, REJECTED) y con un clic la convierten en una Talk, que también aparece en /charlas. Las nuevas propuestas e inscripciones generan notificaciones in-app para los admins.
Mientras el evento no terminó, la página ofrece "agregar a Google Calendar" (un link de plantilla con fechas en UTC y zona horaria de Buenos Aires; si no hay hora de fin asume una hora y lo avisa) y "descargar .ics", generado por un route handler. Las fechas se muestran en la zona horaria de quien visita, en formato 24 h.
El campo shortcut arma URLs cortas para flyers: /[shortcut] busca el próximo evento con ese slug y redirige a él, con sus propias tarjetas de Open Graph. En /eventos los próximos se muestran "en cartelera" y los pasados en un "museo" de flyers agrupado por año y numerado desde el Nº 001; el badge de estado ("Inscripciones abiertas", "Cupo completo", "En curso") se recalcula en el cliente cada minuto. Cada evento tiene además sus fotos de la galería, sus anuncios y una imagen de Open Graph generada.
Las variantes son columnas opcionales del mismo modelo.
model Event {
id String @id @default(cuid())
date DateTime
endDate DateTime?
name String
description String
city String? // Nombre de la ciudad
address String? // Dirección específica (calle, número, etc.)
placeName String? // Nombre del lugar (bar, universidad, etc.)
flyerImages String[] @default([])
googleMapsUrl String? // Link de Google Maps del lugar; de ahí salen el mapa y los links
capacity Int? // Cupo máximo del evento (opcional)
externalRegistrationUrl String? // URL externa de inscripción (ej: Luma)
markedAsFull Boolean @default(false)
callForSpeakersEnabled Boolean @default(false)
isOnline Boolean @default(false)
streamingUrl String?
shortcut String? // Slug para URL corta de flyers (ej: "cowork" → /cowork). Reutilizable entre eventos.
deletedAt DateTime? // Eliminación lógica
// …
createdBy User? @relation("UserCreatedEvents", fields: [createdById], references: [id], onDelete: SetNull)
organizers EventOrganizer[]
galleryItems GalleryItem[]
registrations EventRegistration[]
sponsors Sponsor[]
announcements Announcement[]
talkProposals TalkProposal[]
talks Talk[]
}superRefine valida reglas que dependen de otro campo: la ubicación es obligatoria solo para eventos presenciales.
.superRefine((data, ctx) => {
if (!data.isOnline) {
if (!data.city || data.city.length < 2) {
ctx.addIssue({
code: z.ZodIssueCode.too_small,
minimum: 2,
type: 'string',
inclusive: true,
message: 'La ciudad debe tener al menos 2 caracteres',
path: ['city'],
});
}
// … lo mismo para placeName y addressReglas puras, sin base de datos: se testean con objetos armados a mano.
export function canCreateEvents(user: EventUser | null | undefined): user is EventUser {
return !!user && (isSiteAdmin(user) || user.isAmbassador);
}
const isEventCreator = (user: EventUser, event: EventOwnership) =>
user.isAmbassador && !event.deletedAt && event.createdById === user.id;
// Editar el evento y gestionar sus inscripciones, charlas y propuestas.
export function canEditEvent(user: EventUser | null | undefined, event: EventOwnership) {
if (isSiteAdmin(user)) return true;
if (!user || event.deletedAt) return false;
return (
isEventCreator(user, event) ||
event.organizers.some((organizer) => organizer.userId === user.id)
);
}
// Eliminar el evento y elegir sus organizadores queda para quien lo creó.
export function canDeleteEvent(user: EventUser | null | undefined, event: EventOwnership) {
if (isSiteAdmin(user)) return true;
return !!user && isEventCreator(user, event);
}El wrapper que usan las páginas (getEventManager) y las server actions (requireEventManager).
/** El usuario logueado si puede gestionar el evento, o null. */
export async function getEventManager(eventId: string) {
const user = (await getCurrentSession())?.user;
return (await canManageEventById(user, eventId)) ? user! : null;
}
/** Para Server Actions: lanza un error salvo que quien llama gestione el evento. */
export async function requireEventManager(eventId: string) {
const user = await getEventManager(eventId);
if (!user) throw new Error('No autorizado');
return user;
}Una carpeta con punto en el nombre sirve un archivo: /eventos/:id/calendario.ics.
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const event = await fetchEvent(id);
if (!event) return new Response('Not found', { status: 404 });
return new Response(createIcsFile(event), {
headers: {
'Content-Type': 'text/calendar; charset=utf-8',
'Content-Disposition': `attachment; filename="pcn-evento-${event.id}.ics"`,
'Cache-Control': 'no-store',
},
});
}const location = event.isOnline
? event.streamingUrl
: [event.placeName, event.address, event.city].filter(Boolean).join(', ');
// …
const url = new URL('https://calendar.google.com/calendar/r/eventedit');
url.searchParams.set('action', 'TEMPLATE');
url.searchParams.set('dates', `${utcDateTime(event.date)}/${utcDateTime(endDate)}`);
url.searchParams.set('stz', EVENT_TIME_ZONE);
url.searchParams.set('etz', EVENT_TIME_ZONE);
url.searchParams.set('text', event.name);
url.searchParams.set('details', details);
if (location) url.searchParams.set('location', location);Varios eventos comparten el slug (cada cowork usa "cowork"); siempre gana el próximo.
export async function findNextEventByShortcut(slug: string) {
const now = new Date();
return prisma.event.findFirst({
where: {
deletedAt: null,
shortcut: slug.toLowerCase(),
OR: [{ date: { gte: now } }, { endDate: { gte: now } }],
},
orderBy: { date: 'asc' },
include: { galleryItems: { where: visibleGalleryItem, select: { src: true }, take: 1 } },
});
}Inscribirse parece un simple INSERT, pero tiene varios casos: la persona no tiene sesión, ya estaba inscripta, se había dado de baja y vuelve, el evento se llenó, o la inscripción se maneja en otra plataforma. Además, quien organiza necesita ver la lista y el perfil del público.
@@unique([eventId, userId]) garantiza en la base una sola fila por persona y evento, aunque lleguen dos pedidos a la vez.cancelledAt; volver a inscribirse lo vuelve a null. Así el historial queda y la restricción única se respeta.P2002 de Prisma avisa cuando la base frenó un duplicado.redirect y autoRegister=true; al volver, la página termina la inscripción sola.Inscripción propia (cuando el evento no tiene externalRegistrationUrl): hace falta tener cuenta. Sin sesión, el botón lleva a /autenticacion/iniciar-sesion?redirect=/eventos/:id&autoRegister=true (también funciona desde el registro); al volver, EventDetailClient llama a registerEvent sola y muestra un diálogo de confirmación. Con sesión, el botón llama directo a registerEvent, que decide en el servidor si hay lugar o si la persona va a la lista de espera.
registerEvent tiene rate limit (20 cada 10 minutos) y corre en una transacción que bloquea la fila del evento (SELECT … FOR UPDATE), así dos personas no se quedan con el último lugar. Rechaza si ya hay una inscripción activa o si ya está esperando, cuenta las activas contra capacity (o respeta markedAsFull), reactiva una cancelada o crea una nueva, y notifica a los admins. Cancelar (cancelRegistration) solo puede hacerlo la propia persona y es un soft delete; quienes gestionan el evento pueden además borrar una inscripción definitivamente.
Lista de espera propia (EventWaitlistEntry): si no hay lugar, registerEvent suma a la persona al final de la fila y le muestra su posición. Cuando se libera un lugar (alguien cancela, se borra una inscripción o se edita el cupo), promoteFromWaitlist en src/lib/event-waitlist.ts inscribe en orden de llegada a quienes esperan, dentro de la misma transacción, y notifyPromotions les manda un email y avisa a los admins. Las filas no se borran: promotedAt y cancelledAt guardan quién consiguió lugar y quién se bajó. No promueve si el evento está marcado como lleno a mano o ya terminó.
Inscripción externa: si el evento tiene externalRegistrationUrl, el botón abre esa URL (Luma, por ejemplo) y no se cuenta cupo en el sitio; si está marcado como lleno, el botón pasa a ser unirmeAListaDeEspera(); y lleva a la lista de espera de esa plataforma. El evento se considera completo si tiene markedAsFull o si las inscripciones activas llegaron a capacity; la página lo muestra con "Cupo completo" y, si no, con "Quedan N lugares disponibles.".
Quienes gestionan el evento ven /eventos/[id]/inscripciones: totales de activas y canceladas, cuántas son de estudiantes y cuántas de profesionales (según el perfil), una tabla de TanStack Table con búsqueda, orden por nombre y fecha, y acción para borrar, y la lista de espera en el orden en que se va a promover. El panel de admin muestra una barra de inscriptos sobre el cupo para cada evento próximo.
Lo que todavía no tiene, por si querés contribuir: check-in con QR, emails de confirmación o recordatorio y exportar la lista a CSV.
model EventRegistration {
id String @id @default(cuid())
eventId String
userId String // Usuario registrado (requerido)
cancelledAt DateTime? // Fecha de cancelación (si fue cancelada)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([eventId, userId])
@@index([eventId])
@@index([userId])
@@index([cancelledAt])
}Con la fila del evento bloqueada, los lugares libres pasan a quienes esperan, en orden de llegada.
for (;;) {
if (event.capacity !== null) {
const active = await tx.eventRegistration.count({
where: { eventId: event.id, cancelledAt: null },
});
if (active >= event.capacity) break;
}
const next = await tx.eventWaitlistEntry.findFirst({
where: activeWaitlistWhere(event.id),
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
include: { user: { select: { name: true, email: true } } },
});
if (!next) break;
// …Al volver del login con autoRegister=true, la página completa la inscripción una sola vez.
useEffect(() => {
if (
autoRegister &&
isAuthenticated &&
!isRegistered &&
!isWaitlisted &&
!hasAutoRegistered &&
!externalRegistrationUrl
) {
const performAutoRegister = async () => {
setHasAutoRegistered(true);
setIsAutoRegistering(true);
try {
await registerEvent(eventId, { skipRedirect: true });
// …const isFull = event.markedAsFull || (capacityInfo !== null && !capacityInfo.available);Un "escritorio web" imita un sistema operativo dentro del navegador: barra de menú, dock, ventanas que se mueven y se apilan. La decisión que más pesa es qué hay dentro de cada ventana. Renderizar componentes ahí obliga a duplicar ruteo y estado; usar un iframe por ventana reutiliza las páginas reales tal cual, con su scroll, sus diálogos y su diseño responsive al tamaño de la ventana, pero cada ventana pasa a ser una copia entera de la app. El resto del diseño es administrar ese costo: no cargar el escritorio donde no se usa y ofrecer modos más livianos para compus con pocos recursos.
<html> (data-embedded) dice cuál es cuál.<head> marca atributos en <html> antes del primer paint, así el CSS muestra el layout correcto desde el primer frame. Decidirlo en JavaScript después de hidratar siempre parpadea.import() dinámico crea un chunk aparte que solo se descarga cuando se pide. Pedirlo apenas corre el módulo adelanta la descarga a antes de la hidratación.En pantallas de 1024px o más, PcnOs reemplaza el layout clásico. Cada programa del dock abre una ventana OsWindow con un iframe de la URL real; el estado vive en un useReducer (abrir, enfocar, minimizar, maximizar, mover, redimensionar) y el orden de apilado es un array de ids. Cada ventana guarda src (con la que se creó el iframe, fija para no recargarlo) y path (dónde está ahora). Se mueven desde la barra de título y se redimensionan desde cualquier borde o esquina con pointer events: mientras se arrastra, la ventana se actualiza directo en el DOM una vez por frame (translate al mover) y recién al soltar pasa al reducer, así el escritorio no se re-renderiza en cada movimiento. Siempre quedan dentro del escritorio y con un tamaño mínimo. La sesión (ventanas abiertas, su página, posición, tamaño, estado y orden) se guarda en sessionStorage (os-session.ts): al recargar vuelven todas donde estaban, escaladas si cambió la pantalla, y la de la URL queda al frente.
OsBridge corre dentro de cada ventana: avisa a dónde navegó y cuándo la tocaron, y en fase de captura intercepta los links que deben abrir otra ventana (perfiles, detalle de eventos) antes de que <Link> navegue. El cursor hacker se dibuja una sola vez en el escritorio: las ventanas le reportan el puntero.
El variant de Tailwind os: combina el media query con los atributos de <html>; el servidor manda el escritorio (hidden os:block) y el layout clásico (os:hidden) y el CSS elige. Después de hidratar, OsGate desmonta el árbol que no se ve.
El escritorio carga en dos partes: el fondo, la barra de menú y el estado se renderizan en el servidor y nunca se desmontan; el dock, las ventanas, los widgets, el launcher y el reproductor (con framer-motion) viven en os-desktop-parts.ts y se importan solo en un escritorio. Celulares, tablets y las páginas dentro de ventanas no los descargan. Como el primer paint no cambia, no hay parpadeo.
Hay tres modos: completo, liviano y clásico. El script del <head> elige antes del paint: la elección guardada, o liviano si la compu tiene 4 núcleos o menos, 4 GB o menos, o ahorro de datos. Si nadie eligió, el escritorio además mide sus frames unos segundos: si traba pasa a liviano, y si en liviano sigue trabando sugiere el clásico. Cuando el liviano fue automático, un aviso explica que no se está viendo la experiencia completa por los recursos de la compu. El modo se cambia desde el menú PCN_OS, y las ventanas abiertas lo siguen por el evento storage.
El modo liviano apaga, en orden de costo: deja solo 3 ventanas con su página cargada (las demás quedan en pausa y se recargan donde estaban al volver), quita backdrop-filter y el brillo desenfocado del fondo, los widgets y el cursor hacker, la magnificación del dock y las animaciones de ventanas. El clásico no tiene escritorio ni iframes. La documentación completa está en docs/pcn-os-y-rendimiento.md.
La parte pesada del escritorio es un chunk aparte. En un escritorio la descarga arranca apenas corre el módulo; en el resto nunca se pide.
let desktopParts: Promise<OsDesktopParts> | null = null;
const loadDesktopParts = () => (desktopParts ??= import('./os-desktop-parts'));
// On a desktop host, start downloading right away, while the page is still hydrating, so the
// dock and the first window show up as early as before. Elsewhere it is never requested.
if (typeof window !== 'undefined' && isOsHost()) void loadDesktopParts();
/** The heavy desktop components, once the desktop is active and they have loaded. */
const useDesktopParts = (enabled: boolean) => {
const [parts, setParts] = useState<OsDesktopParts | null>(null);
useEffect(() => {
if (!enabled || parts) return;
let cancelled = false;
void loadDesktopParts().then((loaded) => {
if (!cancelled) setParts(loaded);
});
return () => {
cancelled = true;
};
}, [enabled, parts]);
return parts;
};El escritorio solo acepta mensajes de su propio origen y de sus propias ventanas.
const onMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin || !isOsMessage(event.data)) return;
const id = [...iframes.current].find(([, f]) => f.contentWindow === event.source)?.[0];
if (!id) return;
if (event.data.type === 'focus') dispatch({ type: 'focus', id });
if (event.data.type === 'location')
dispatch({ type: 'location', id, path: event.data.path, title: event.data.title });
if (event.data.type === 'open' && viewport)
dispatch({ type: 'openPath', path: event.data.path, viewport });
// …
};En liviano, solo las ventanas visibles más recientes mantienen su página.
const liveWindowIds = new Set(
state.order
.filter((id) => !state.windows.find((win) => win.id === id)?.minimized)
.slice(-LITE_LIVE_WINDOWS),
);Una ventana en pausa se recarga en la página donde estaba, no en la que se abrió.
const [src, setSrc] = useState(win.src);
const [prevSuspended, setPrevSuspended] = useState(suspended);
if (suspended !== prevSuspended) {
setPrevSuspended(suspended);
if (suspended) {
setSrc(win.path);
setLoaded(false);
}
}La medición cuenta frames largos durante 5 segundos; más del 20% es una compu que traba.
const tick = (time: number) => {
if (!start) {
start = last = time;
} else {
frames += 1;
if (time - last > LONG_FRAME_MS) longFrames += 1;
last = time;
}
if (time - start < SAMPLE_MS) {
frame = requestAnimationFrame(tick);
return;
}
if (!aborted && frames > 0 && longFrames / frames > SLOW_SHARE) onSlow();
};El primer paint útil (y el LCP, el elemento más grande en pantalla) depende de lo que el HTML del servidor ya muestra. Todo lo que espera a que el JavaScript hidrate llega tarde: un contenido que arranca invisible para animar su entrada, un número que se llena en el cliente, una sección estática mandada como componente de cliente. La receta es renderizar en el servidor todo lo que no es interactivo, animar con CSS en vez de JavaScript, y dejar que el navegador saltee el trabajo de lo que está fuera de pantalla.
opacity: 0 no cuenta hasta que se ve, así que una entrada animada en JS retrasa el LCP lo que tarde el bundle.'use client' arrastra al bundle todo lo que importa. Las secciones estáticas no necesitan ser de cliente.animation-timeline: view() ata el progreso de una animación CSS a cuánto entró el elemento en la pantalla. Reemplaza al IntersectionObserver para apariciones al scrollear.<integer>) para que se pueda interpolar en una animación. Con un contador de CSS se imprime un número que sube sin JavaScript.content-visibility: auto saltea el layout y el pintado de lo que está lejos de la pantalla; contain-intrinsic-size reserva su alto para que el scroll no salte.La home (page.tsx) solo espera la sesión, que el layout ya leyó; cada sección con datos llega por streaming detrás de un skeleton. El envoltorio de las secciones, home-sections.tsx, es un server component: las secciones estáticas (bento, logros, preguntas, footer…) llegan como HTML y solo hidratan las hojas interactivas, como los reproductores o el botón de instalar la app.
El hero y las apariciones al scrollear usaban framer-motion con opacity: 0 inicial, así que la página aparecía recién después de hidratar. Ahora el hero usa las clases de tailwindcss-animate (el título solo se desliza, sin fade, porque es lo más grande de la pantalla) y Reveal es un div con una animación CSS ligada al scroll; donde no hay soporte, el contenido simplemente está.
Los números del hero (500+ miembros…) se renderizaban vacíos en el servidor porque los escribía NumberTicker en el cliente. Ahora CountUp los anima solo con CSS y el valor real va además en un sr-only para lectores de pantalla.
La foto de fondo del hero es de 3024px y 2.7 MB y se ve al 22% de opacidad bajo dos gradientes, así que se sirve con quality={40}. Next 16 solo acepta las calidades declaradas en images.qualities; sin declararla, la home tiraba un error de React que encontramos bisecando los cambios.
Dos trampas de los server components: una constante exportada desde un archivo 'use client' llega a un server component como referencia de cliente y no como valor (por eso WHATSAPP_GROUP_URL vive en src/data/whatsapp-group.ts), y lo mismo pasa con el script inline que elige el modo de PCN OS, que vive en un archivo sin la directiva.
Aparición al scrollear sin JavaScript, y sin layout ni pintado hasta que la sección se acerca.
.reveal {
content-visibility: auto;
contain-intrinsic-size: auto 600px;
}
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.reveal {
animation: reveal-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 35%;
}
}
}Un entero registrado que se anima de 0 al valor y se imprime con un contador.
@property --count {
syntax: '<integer>';
initial-value: 0;
inherits: false;
}
.count-up {
--count: var(--count-to);
counter-reset: count var(--count);
animation: count-up 1.8s cubic-bezier(0.22, 1, 0.36, 1) 0.3s both;
}
.count-up::after {
content: counter(count);
}
@keyframes count-up {
from {
--count: 0;
}
}const CountUp = ({ value }: { value: number }) => (
<span className="tabular-nums tracking-tight">
<span aria-hidden className="count-up" style={{ '--count-to': value } as CSSProperties} />
<span className="sr-only">{value}</span>
</span>
);images: {
// 75 is the default; 40 is for the home hero backdrop, shown faded under gradients.
qualities: [40, 75],
// …
},Un cursor personalizado esconde el nativo (cursor: none) y dibuja uno propio con elementos posicionados en fixed que se mueven con cada pointermove. El riesgo es que se sienta lento o trabe: hay que moverlo solo con transform (lo resuelve la GPU, sin recalcular el layout), agrupar las actualizaciones en un requestAnimationFrame y dejar de animar cuando no hay nada que mover. Las animaciones con inercia ("lerp": recorrer una fracción de la distancia que falta en cada frame) tienen que depender del tiempo y no de la cantidad de frames, o en una pantalla de 120 Hz van el doble de rápido que en una de 60 Hz.
pointermove, pointerdown y pointerup unifican mouse, touch y lápiz; pointerType dice cuál es, y el cursor solo reacciona a mouse.r, en n frames se recorre 1 - (1 - r)^n. Con n medido desde el último frame, la inercia se siente igual en cualquier pantalla.requestAnimationFrame se pide solo cuando algo se mueve y deja de pedirse cuando los corchetes llegaron: con el mouse quieto el cursor no gasta nada.HackerCursor está montado en el layout raíz y solo se activa con (hover: hover) and (pointer: fine), sin prefers-reduced-motion y en el modo completo de PCN OS (en liviano y clásico queda el nativo). Recién cuando se activa agrega la clase pcn-cursor a <html>, que es la que esconde el cursor nativo: si el JS no corre, la página nunca queda sin puntero. Los campos de texto conservan el I-beam.
El cuadrado sigue al mouse exacto y los corchetes lo persiguen con inercia (RING_FOLLOW, la fracción por frame de 60 Hz, escalada al tiempo real del frame). Sobre algo clickeable los corchetes crecen y una etiqueta dice qué hace el click: cd para links internos, open ↗ para externos, exec para botones, o el texto de un atributo data-cursor. Al soltar el click sale una ráfaga de caracteres hex que se borran solos al terminar su animación.
En PCN OS cada ventana es un iframe y el iframe se queda con los eventos del mouse. Para no tener dos cursores (o uno congelado en el borde), dentro de una ventana el componente no dibuja nada: esconde el nativo y le manda al escritorio cada movimiento por postMessage, con qué hay debajo. El escritorio busca de qué iframe vino el mensaje y le suma su getBoundingClientRect() para pasar las coordenadas a las suyas. Solo la última ventana que reportó puede esconder el cursor, porque un blur puede llegar tarde.
La inercia de los corchetes, escalada al tiempo real de cada frame.
const render = (now: number) => {
frame = 0;
// Frames elapsed at 60 Hz since the last render; capped so a stalled tab doesn't teleport.
const frames = lastFrame ? Math.min((now - lastFrame) / (1000 / 60), 4) : 1;
lastFrame = now;
// Ease the brackets towards the pointer; snap once they're close enough to stop the loop.
const follow = ease(RING_FOLLOW, frames);
ringPos.x += (target.x - ringPos.x) * follow;
ringPos.y += (target.y - ringPos.y) * follow;
// …
if (settled) lastFrame = 0;
else frame = requestAnimationFrame(render);
};El escritorio pasa el puntero que reporta una ventana a sus propias coordenadas.
const frameElement = [...document.querySelectorAll('iframe')].find(
(iframe) => iframe.contentWindow === event.source,
);
if (!frameElement) return;
// Window coordinates are relative to its page; shift them onto the desktop.
const rect = frameElement.getBoundingClientRect();
const x = rect.left + event.data.x;
const y = rect.top + event.data.y;
moveTo(x, y, event.data);Una PWA instalada corre sin la interfaz del navegador, y con ella pierde el gesto de tirar hacia abajo para recargar. Reimplementarlo es escuchar los toques, decidir cuándo un arrastre es un "pull" (desde arriba de todo, claramente vertical, sin un diálogo ni un scroll interno de por medio), mostrar un indicador con resistencia y, al soltar, volver a pedir los datos sin recargar la página entera.
navigator.standalone.preventDefault en touchmove, que solo funciona si el listener se registró con passive: false.startTransition con una función async mantiene isPending en verdadero hasta que termina, ideal para sostener un spinner mientras llegan los datos.PullToRefresh está montado en el layout de (platform) y solo se activa instalada y con puntero táctil; en el navegador queda el gesto nativo. Las páginas con contenido que vive en el repo (cursos, videos, podcast…) están en una lista de excepciones: las páginas nuevas tienen pull to refresh por defecto.
Al soltar pasado el umbral se llama a router.refresh() para los server components y a invalidateQueries() para lo que usa React Query, todo dentro de startTransition, y el indicador gira hasta que isPending vuelve a falso (con un mínimo de 600 ms para que se lea como "se actualizó").
if (pullRef.current >= THRESHOLD) {
setDistance(THRESHOLD);
if (navigator.vibrate) navigator.vibrate(10);
startTransition(async () => {
router.refresh();
await Promise.all([
queryClient.invalidateQueries(),
new Promise((resolve) => window.setTimeout(resolve, MIN_SPIN_MS)),
]);
});
} else {
setDistance(0);
}¿Querés conocer más herramientas del ecosistema? ~/herramientas →
*.test.ts) colocalizados junto al código que prueban: server actions, src/lib, schemas y route handlers. Se ejecutan con pnpm test o en modo watch con pnpm test:watch.tests/ que corren en Chromium, Firefox y WebKit. Se ejecutan con npx playwright test.Las técnicas, los checks y todos los casos de prueba, manuales y automatizados, en ~/desarrollo/calidad →

Agus@agustin-sanc
Tech Lead & Sr. Full-Stack Engineer · Dizenz & Eagerworks

Facu@FacuBzn
Ssr. Backend (JS/TS) · C&S Informática

Mauri@MauriJC
Ssr. Full-Stack (JS/TS) · Dizenz

Germán@gmanavarro
Sr. Backend (JS/TS) · Entropy

Nico@nicofuentesg
Ssr. Frontend (JS/TS) · Dizenz

Mati@MatiasDG539
Jr. Engineer · Eagerworks

Lemi@emilianogsh
Ssr. QA Engineer · Dizenz

Alejo@Alejoboga20
Sr. Full-Stack (TS/Python) · Pendo.io

Carlos@SpagnoloCarlos
Sr. Frontend (JS/TS) · WebExport
Maxi@MaxiR23
Contributor
Lean@contrera-lean
Contributor
Facu M.@facmartoni
Sr. Full-Stack & AI Engineer

Chelo@Chelo154
Sr. Backend (Python & Java) · Bowery

Benja@cortesjpb
Sr. Full-Stack Engineer

Vicky@vickygrillo
Ssr. QA Engineer · Dizenz
FedericoV21
Contributor
luki1qq
Contributor
shadownrx
Contributor
commits
1.918
PRs mergeadas
101
3 abiertas
contribuidores
17
merge (mediana)
2h
de PR abierta a merge
stars
21
forks
9
$ git log --since="52 weeks ago" | wc -l1.236· 15 semanas activas
$ git log --numstat | awk '{a+=$1; d+=$2} END {print a-d}'141.466líneas de código
$ github-linguist --breakdown
$ gh pr list --state merged | sort -rn· 17 personas
repo creado hace 2 años · datos de GitHub al 7 de octubre de 2026

Agus@agustin-sanc
Tech Lead & Sr. Full-Stack Engineer · Dizenz & Eagerworks

Facu@FacuBzn
Ssr. Backend (JS/TS) · C&S Informática

Mauri@MauriJC
Ssr. Full-Stack (JS/TS) · Dizenz

Germán@gmanavarro
Sr. Backend (JS/TS) · Entropy

Nico@nicofuentesg
Ssr. Frontend (JS/TS) · Dizenz

Mati@MatiasDG539
Jr. Engineer · Eagerworks

Lemi@emilianogsh
Ssr. QA Engineer · Dizenz

Alejo@Alejoboga20
Sr. Full-Stack (TS/Python) · Pendo.io

Carlos@SpagnoloCarlos
Sr. Frontend (JS/TS) · WebExport
Maxi@MaxiR23
Contributor
Lean@contrera-lean
Contributor
Facu M.@facmartoni
Sr. Full-Stack & AI Engineer

Chelo@Chelo154
Sr. Backend (Python & Java) · Bowery

Benja@cortesjpb
Sr. Full-Stack Engineer

Vicky@vickygrillo
Ssr. QA Engineer · Dizenz
Contributor
Contributor
Contributor