Skip to content

About

Hire me, Blog, About me, Projects, Contact. Easy to manage from the phone.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Español | English

¡Robate este portfolio! 🦝

Un esqueleto de portfolio. El nombre, los textos, los proyectos y los colores salen de D1 y se editan en el modo admin, no desde GitHub. Un deploy de ejemplo está en https://lulox.com.ar. Un portfolio simple y bilingüe (ES/EN) con blog, editor inline para el admin y una calculadora de presupuesto, todo en un solo Cloudflare Worker. Forkealo, cambiá el contenido y publicalo. Esa es toda la idea. ✨ Es copyleft a propósito: llevátelo, mejoralo y dejalo abierto para el que venga. 🏴‍☠️

✨ Qué trae

  • 🏠 Inicio: foto, nombre y menú (Contratar, Blog, Sobre mí, Proyectos, Contacto). En desktop ocupa la pantalla justa y en el celu entra en la primera pantalla.
  • ✍️ Blog con editor Markdown/WYSIWYG, borradores, portadas (subidas a R2) y reordenar secciones arrastrando.
  • 👤 Sobre mí: tu historia, las cards de experiencia y tus publicaciones (citas de artículos), editables inline como admin.
  • 🧪 Proyectos: cards y páginas de detalle.
  • 💸 Contratar: calculadora de presupuesto y un formulario que te manda por mail el mensaje y el presupuesto.
  • 🌗 Tema claro y oscuro, 🇦🇷/🇺🇸 español e inglés.
  • 🔐 Login con Google para cualquiera; admin solo los emails que permitas.
  • 🤝 Referidos: cualquiera con cuenta de Google crea su código y gana el 15% de lo que abone el cliente que traiga; registro de pagos para el admin.
  • 🎛️ Panel "Personalizar" (foto de perfil del header, solo admin): secciones, colores, info personal y proyectos, todo guardado en D1.

🧰 Stack

  • Next.js (App Router), compilado para Cloudflare con OpenNext
  • Cloudflare Workers + D1 (SQLite) + R2 (imágenes y archivos) + Rate Limiting
  • Better Auth con login de Google
  • Resend para el mail del formulario (con send_email de Cloudflare Email Routing como respaldo)
  • Tailwind CSS 4, TypeScript y tsx --test para los tests

🚀 Arranque rápido

Necesitás Node.js 24.22.1+ (la última LTS; está en .nvmrc, corré nvm use) y una cuenta gratis de Cloudflare (solo para publicar). Te recomiendo pnpm (está fijado en package.json); npm también funciona.

# 1. Instalar
pnpm install                  # o: npm install

# 2. Variables (secretos locales, ignorados por git)
cp .dev.vars.example .dev.vars
# completá BETTER_AUTH_SECRET, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, TURNSTILE_SECRET_KEY (RESEND_API_KEY es opcional)

# 3. Base local (D1 en .wrangler/state) + posts de ejemplo
pnpm db:migrate:local         # o: npm run db:migrate:local
pnpm seed:blog                # o: npm run seed:blog

# 4. Correrlo
pnpm dev                      # o: npm run dev  → http://localhost:3000

Variables

Nombre Dónde Qué es
BETTER_AUTH_SECRET secret String aleatorio para las sesiones
BETTER_AUTH_URL opcional URL pública del sitio
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET secret Cliente OAuth de Google. Redirect: <sitio>/api/auth/callback/google
RESEND_API_KEY secret opcional Envía el mail del formulario
TURNSTILE_SECRET_KEY secret Clave secreta de Cloudflare Turnstile. El formulario de /contratar verifica el token en el server (siteverify) antes de mandar el mail; sin ella responde 503. La site key pública está en src/content/site.ts (turnstileSiteKey); si forkeás, creá tu widget (dominios: el tuyo y localhost) y cambiala
CONTACT_RL, REF_CHECK_RL ratelimits en wrangler.jsonc Rate limit del formulario y del alta de referidos (5/min por IP) y del chequeo de códigos en vivo (60/min por IP)
SITE_RL, API_RL, EXEMPT_RL ratelimits en wrangler.jsonc Tope por IP en worker.ts (src/lib/rate-limit.ts): 100 pedidos / 10 s en páginas, 40 / 10 s en /api/*; pasado el tope, 429 con Retry-After: 10 antes de que corra Next. El dueño (ADMIN_EMAILS) y los emails de Personalizar → Info personal → Sin límite de pedidos (site_settings.privileged_emails, /api/admin/privileged) no se cortan; la sesión solo se mira cuando se pasa el tope (EXEMPT_RL, 5 / 10 s). Aproximado y por ubicación de Cloudflare. Los archivos estáticos no pasan por el Worker.
CONTACT_TO, CONTACT_FROM, CONTACT_INBOX vars en wrangler.jsonc A dónde llega el mail del formulario, el remitente (un dominio verificado en Resend) y la casilla para tu copia

En local van en .dev.vars. En producción: npx wrangler secret put <NOMBRE>.

Comandos de todos los días

pnpm npm Hace
pnpm test npm test Tests (tests/*.test.mts)
pnpm typecheck npm run typecheck Chequeo de TypeScript
pnpm lint npm run lint ESLint
pnpm cf:build npm run cf:build Build de producción para Cloudflare
pnpm preview npm run preview Build + el Worker real en local
pnpm run deploy npm run deploy Build + publicar en Cloudflare

☁️ Publicar en Cloudflare

  1. npx wrangler login
  2. Creá tus recursos y poné nombres/ids en wrangler.jsonc:
    • npx wrangler d1 create <tu-db> (copiá el database_id)
    • npx wrangler r2 bucket create <tu-bucket>
    • cambiá name, routes (tu dominio), el destino de send_email y vars (CONTACT_TO, CONTACT_FROM, CONTACT_INBOX)
  3. pnpm db:migrate:remote (el script usa el binding DB de wrangler.jsonc, no el nombre de la base: no hace falta tocar package.json)
  4. Secrets: npx wrangler secret put BETTER_AUTH_SECRET (igual para los de Google, TURNSTILE_SECRET_KEY y RESEND_API_KEY)
  5. pnpm run deploy (si falla una vez, volvé a correrlo)

🎨 Hacelo tuyo

Primero esto 👇

  • src/content/site.ts: secciones del menú y la site key pública de Turnstile. El nombre, las redes, los gustos, la agenda y los colores viven en D1 (site_settings, migración 0013) y se editan en el modo admin (PUT /api/settings). Sin filas, el sitio usa defaults neutros (DEFAULT_SITE en src/lib/site-settings.ts).
  • src/lib/auth/admin.ts: ADMIN_EMAILS, las únicas cuentas de Google que pueden editar. Cualquier otra cuenta puede ingresar, pero es un usuario común: sin modo edición, sin admin, sin borradores (todas las APIs de admin responden 401/403), y solo ve su propio panel de referidos.
  • src/lib/auth/options.ts: ALLOWED_HOSTS y TRUSTED_ORIGINS (tus dominios, si no el login los rechaza).
  • public/: reemplazá las fotos (avatar*.jpg), imágenes de proyectos y logos.
  • Ícono del sitio (favicon, Apple Touch, PWA): monograma «lx» sobre fondo negro a sangre. El original está en brand/lulox-logo-master.jpg y python3 scripts/make-icons.py genera los PNG/ICO livianos de public/icons (nombres con versión .v2 para refrescar cachés; public/manifest.v2.webmanifest, fondo y tema #000000). Copia canónica en R2: https://assets.lulox.com.ar/lulox/logo/v1/. Si forkeás, reemplazá brand/ y corré el script.
  • public/og.png: la imagen para compartir (og:image/twitter:image, 1200x630), recorte desde arriba de avatar-hero.jpg. Si cambiás la foto, regenerala. Los posts con portada usan su propia portada.
  • URLs en dos idiomas: el español vive en la raíz y el inglés bajo /en con rutas traducidas: / ↔ /en, /contratar ↔ /en/hire, /sobre-mi ↔ /en/about, /proyectos(/<id>) ↔ /en/projects(/<id>), /contacto ↔ /en/contact, /privacidad ↔ /en/privacy, /blog ↔ /en/blog, /blog/<slug> ↔ /en/blog/<slug_en>, /ingresar ↔ /en/sign-in, /referidos ↔ /en/referrals (/en/tastes y /en/gustos van a /en/about). El idioma lo define la URL, para el contenido y para la metadata: src/proxy.ts pone el header x-lulox-locale según la ruta (pisa el que mande el cliente) y reescribe /en/... a la página en español que la renderiza. La cookie lulox-locale solo guarda lo último que elegiste en el selector; no se usa para redirigir ni para elegir idioma (tampoco Accept-Language). El selector lleva a la misma página en el otro idioma (en un artículo, a su otro slug, leyendo el hreflang de la página). El mapa vive en src/lib/routes.ts (SEGMENTS_ES_TO_EN); los links internos se escriben en español y pasan por href() de useLocale(). Las URLs viejas en inglés de la raíz (/about, /projects, /contact, /privacy, /login) siguen redirigiendo igual que antes a las españolas.
  • Tarjetas para compartir (SEO): cada URL tiene su og:title, og:description, twitter:*, og:url y canonical propios, en el idioma de la URL: og:locale es_AR en la raíz y en_US bajo /en (no dependen de cookies ni de Accept-Language). Cada página lleva hreflang es-AR, en-US y x-default (= español), y sitemap.xml lista las dos versiones de cada página con sus alternates. Formato "Página | {nombre}"; la home queda con el nombre del sitio (D1 site_settings, default "Portfolio"). Los artículos usan su título y su summary en ese idioma (si falta el resumen, el comienzo del artículo); las demás páginas tienen su descripción corta propia en cada idioma. El helper está en src/lib/page-metadata.ts (pageMetadata("/ruta-en-español", { es, en })); el texto de cada ruta vive en su layout.tsx (o en privacidad/page.tsx). Si agregás una ruta, sumala a SEGMENTS_ES_TO_EN y dale su pageMetadata(...).
  • wrangler.jsonc y el link del footer en src/app/page.tsx.

Sección por sección

Sección Ruta Archivos Cómo se edita
Inicio + menú / src/app/page.tsx, src/components/section-nav.tsx, src/components/hire-button.tsx Ítems y orden por defecto: sections en src/content/site.ts (hire es la píldora que brilla). El admin puede ocultarlos y reordenarlos desde el panel "Personalizar" (ver "Panel de admin"). En desktop el nombre del sitio (D1) queda centrado sobre los botones del menú, sin línea debajo.
Blog /blog, /blog/[slug], /en/blog, /en/blog/<slug_en> src/app/blog/, src/components/views/blog-*.tsx, src/lib/blog.ts Los posts viven en D1 (blog_posts). Entrá como admin y escribí en /blog/nuevo o editá cualquier post. Ejemplos: src/content/blog/seed.ts. Cada post tiene slug (español, clave) y slug_en (inglés, columna de la migración 0007_blog_slug_en.sql, rellenada desde title_en). En el editor se cambian los dos ("slug (ES)" y "slug (EN)"); el server valida que slug_en sea kebab-case en minúsculas sin tildes y que no lo use otro post (ni como slug_en ni como slug). Si queda vacío, al publicar se genera desde title_en; un borrador puede quedar sin slug_en y se sirve en /en/blog/<slug>. /en/blog/<slug> (slug en español) redirige 301 a /en/blog/<slug_en> y /blog/<slug_en> redirige 301 a /blog/<slug>. Antes de deployar este cambio hay que aplicar la migración 0007 (pnpm db:migrate:remote). Borradores: solo los ve el admin con el modo edición encendido (cookie lulox-edit-mode, espejo del localStorage, ver src/lib/edit-mode.ts); con el modo edición apagado el admin ve el blog como un visitante (sin borradores en la lista y el slug de un borrador da 404).
Extras del editor src/components/blog-post-edit.tsx, md-wysiwyg.tsx, section-reorder.tsx, src/lib/article-sections.ts Portadas e imágenes van a R2 (/api/media, servidas en /media/...). "Orden de secciones" corta el post por títulos y --- y te deja arrastrarlas.
Sobre mí /sobre-mi src/app/sobre-mi/page.tsx, src/components/views/about-view.tsx, src/lib/page-layout.ts, src/lib/md-sections.ts Texto y cards de experiencia (about.timeline) en src/i18n/messages.ts. Como admin editás inline; se guarda en D1 (site_copy) y gana sobre el código. La animación de las secciones (el vistazo a las primeras líneas de "Primeros pasos" y el sacudón del botón) se pausa cuando sale de pantalla y sigue al volver (IntersectionObserver en src/components/copy-markdown.tsx); con prefers-reduced-motion no hay animación.
Publicaciones (Sobre mí) /sobre-mi, /en/about, PUT /api/publications src/components/publications.tsx, src/lib/publications.ts, src/lib/publications-db.ts, src/lib/publications-api.ts, src/app/api/publications/route.ts, src/components/views/about-view.tsx, migrations/0011_publications.sql, scripts/sql/about-publications-copy.sql Un bloque propio "Publicaciones" / "Publications" para citar artículos, distinto de las cards de experiencia: lista numerada con título (link en pestaña nueva, rel="noopener noreferrer", toda la entrada es clickeable), bajada corta por idioma y la cita (autores · año · medio). Va justo debajo del relato "Agentes e investigación" / "Agents and research": en el layout (about.layout) es el token pubs:0, después de story:4; un layout guardado sin el token lo pone después del último relato, y un cuerpo sin secciones ## lo muestra al final del texto. El título se muestra como fue publicado; title_en solo se usa si existe una versión real en inglés. Sin filas en D1 (o sin la tabla) no se muestra ninguna publicación (DEFAULT_PUBLICATIONS está vacío). Como admin con el modo edición prendido: agregar, editar (título, título en inglés, bajada ES/EN, autores, año, medio, link), reordenar (↑ ↓), quitar y restaurar; los cambios quedan locales hasta "Guardar" (PUT /api/publications, 401 sin sesión, 403 si no sos admin o con el modo edición apagado). Validación estricta (validatePublicationsPayload): lista completa (máx. 30), ids a-z 0-9 -, links solo https:// sin usuario/clave (máx. 600), título 1 a 300, bajadas hasta 160, autores hasta 400, medio hasta 80, año entero 1900 a 2100 o vacío, hidden booleano, sin campos extra. D1 publications: solo INSERT ... ON CONFLICT DO UPDATE y UPDATE; quitar es hidden = 1 (nunca DELETE) y una fila que no viene en el guardado queda oculta. Los visitantes (y el admin con el modo edición apagado) nunca reciben las ocultas; /sobre-mi se renderiza por request (force-dynamic). Texto viejo: si un relato todavía termina con la lista de links a estas publicaciones, se omite al mostrarlo (stripPublicationLinks); en modo edición se ve el texto guardado tal cual. El cambio de copy en D1 (cerrar la frase sin los links, ES y EN) es manual: scripts/sql/about-publications-copy.sql (UPDATE por key + locale, solo si el texto actual coincide; correrlo dos veces no hace nada). Antes de deployar hay que aplicar la migración 0011 (pnpm db:migrate:remote); sin ella el sitio muestra los valores por defecto, pero guardar falla.
Links (LinkedIn + gustos) al final de /sobre-mi src/app/sobre-mi/page.tsx, src/lib/site-settings.ts, textos en src/i18n/messages.ts (about.linkedInCta/about.linkedInDetail, tastes.platforms) Un solo grupo de botones sin título: LinkedIn (si hay una red con ese id), Letterboxd, Last.fm y Spotify. Los href salen de D1 (socials y taste_letterboxd / taste_lastfm / taste_spotify). Un href vacío no se renderiza. /tastes y /gustos redirigen a /sobre-mi. Todo lo clickeable lleva cursor: pointer (deshabilitado: not-allowed).
Proyectos /proyectos, /proyectos/[id], PUT /api/projects src/lib/projects.ts, src/lib/projects-db.ts, src/components/projects-admin.tsx, src/components/views/project*.tsx, migrations/0012_projects.sql Los proyectos viven en D1 (projects: id, orden, visible, estado live/disabled/archive, ícono, GitHub, URL en vivo, título y texto por idioma). Con la tabla vacía, /proyectos muestra un estado neutro (projects.empty) y la home no lista ninguno. Como admin con el modo edición: en /proyectos se agregan, editan (título, ícono, URL en vivo, GitHub, estado), reordenan, ocultan y restauran (PUT /api/projects, 401 sin sesión, 403 si no sos admin o el modo edición está apagado). Ocultar es visible = 0, nunca DELETE. La página de detalle edita párrafos, equipo y premios. src/content/projects.ts solo guarda los temas de research, no la lista. Las tablas viejas project_overrides y project_settings quedan en D1 pero la lista pública ya no las lee. Antes de deployar hay que aplicar la migración 0012 (pnpm db:migrate:remote).
Panel de admin (Personalizar) todo el sitio src/components/profile-menu.tsx, src/components/admin-panel.tsx, src/components/admin-settings.tsx, src/components/tip.tsx, src/components/admin-context.tsx, src/components/site-order-context.tsx, src/lib/site-sections.ts, src/lib/site-order.ts, src/app/api/sections/route.ts, src/app/api/settings/route.ts, migrations/0008_site_sections.sql Sin sesión el header muestra "Entrar" y no existe ninguna UI de admin. Con sesión muestra la foto de perfil de Google (la lee src/lib/viewer.ts): al tocarla abre un menú con "Personalizar" (solo admin) y "Salir". "Personalizar" abre un panel modal (se cierra con ✕, Esc o tocando afuera) con cuatro pestañas. Secciones: el switch "Editar textos en la página" (el modo edición de siempre) y la lista de secciones; cada fila tiene el nombre (link que abre y edita esa página), un switch Visible/Oculta y ↑ ↓ para ordenar. Apariencia: colores del modo oscuro y del claro (selector de color + hex, con nombres claros). Info personal: foto principal (botón "Cambiar foto": sube la imagen a R2 vía POST /api/media y la guarda al toque en hero_photo, o se pega una dirección; la foto actual es el default), identidad, imágenes, links, contacto y gustos. Proyectos: lista con Visible/Oculta, ↑ ↓ y link para editar cada uno; "Agregar o editar proyectos" lleva a /proyectos. El panel no tiene textos explicativos: la ayuda de cada control va solo en un tooltip (hover en desktop, mantener apretado en el celu). Todo se guarda en D1: el orden y la visibilidad con PUT /api/sections (solo admin; secciones en site_sections, proyectos en projects) y la identidad y los colores con PUT /api/settings (solo se mandan los campos que cambiaron, y exige el modo edición encendido: con el modo apagado "Guardar" queda deshabilitado). Ocultar no borra nada: una sección oculta desaparece del menú de la home, de los links del pie y de otras páginas, del sitemap.xml y de las APIs públicas, y sus páginas dan 404 en español y en /en (lo decide src/proxy.ts, isPathHidden). Un proyecto oculto sale de /proyectos, del sitemap y de /api/projects/public. El admin con el modo edición encendido sigue viendo y abriendo lo oculto, atenuado; con el modo apagado ve el sitio como un visitante. Sin filas en site_sections se ven todas las secciones en el orden de src/content/site.ts. Antes de deployar hay que aplicar las migraciones 0008, 0012 y 0013 (pnpm db:migrate:remote).
Referidos /referidos, /en/referrals, /admin/referidos src/app/referidos/, src/components/views/referrals-view.tsx, src/app/admin/referidos/, src/components/views/referrals-admin.tsx, src/lib/referrals.ts, src/lib/referrals-db.ts, src/lib/referrals-api.ts, src/lib/viewer.ts, src/app/api/referrals/, src/app/api/admin/referrals/, migrations/0009_referrals.sql Quien trae un cliente gana el 15% (REFERRAL_PCT) de lo que ese cliente abone. Alta: /referidos explica el programa a cualquiera; sin sesión muestra una frase corta y el botón "Entrar con Google" ahí mismo (vuelve a /referidos, el componente GoogleSignInButton de sign-in-panel.tsx), y abajo los tres pasos. Con sesión aparece el alta: nombre prellenado de Google, email de la cuenta (no editable), WhatsApp opcional y un código propio (a-z, 0-9 y guiones internos, 3 a 24, se guarda en minúsculas, chequeo en vivo con debounce contra GET /api/referrals/check, palabras reservadas bloqueadas en RESERVED_CODES). Un código por cuenta (referrers.user_id único). Después ve su link (/contratar?ref=CODIGO y /en/hire?ref=CODIGO) con botón de copiar, cuántos clientes trajo y lo que tiene por cobrar y cobrado; solo sus datos, buscados por el user_id de la sesión en el server (GET /api/referrals). Atribución: ?ref=CODIGO en cualquier página guarda la cookie lulox-ref 90 días si el código existe y está activo (gana el último válido; lo hace src/proxy.ts). /contratar y /en/hire muestran "Referido por: CODIGO" / "Referred by: CODIGO" y un campo editable con el código (así un referido arma el presupuesto y se lo pasa al cliente). /api/hire-contact guarda cada envío en hire_leads con el presupuesto recalculado en el server y el código solo si está activo, y suma "Referido por: código (nombre)" al mail. Registro: /admin/referidos (solo admin; para cualquier otro es 404, lo decide src/proxy.ts y la página lo vuelve a chequear) lista referidos con contacto, código, fecha, botones de mail y wa.me, y sus clientes; en cada uno cargás lo que abonó (ARS o USD) y el server calcula el 15% (referral_payouts), con estado pendiente o pagado y fecha. Arriba de cada referido, lo que le debés por moneda. Desactivar un referido no borra nada: su código deja de atribuir. El admin también puede crear su propio código en /referidos. La sección se oculta desde el panel Personalizar como las demás (404, fuera del menú y del sitemap). En la home el botón Referidos solo aparece con sesión iniciada. Antes de deployar hay que aplicar la migración 0009 (pnpm db:migrate:remote).
Contacto /contacto src/app/contacto/page.tsx Muestra las redes de D1 (site_settings.socials) y el botón de contratar. Un href vacío no se renderiza.
Contratar /contratar src/components/hire-calculator.tsx, src/lib/pricing.ts Tarifas, tamaños, descuentos y límites por defecto en PRICING (pricing.ts, con tests en tests/pricing.test.mts); los montos se pueden cambiar desde la página (ver "Montos de Contratar"). Todos los textos con montos ("+US$ X", tarifas, "Anticipo (X%)", la nota de anticipo y la fórmula con los descuentos) salen de los montos guardados. Formulario "Escribime": campos con id/name/autocomplete (hire-name, hire-email, hire-message), mensaje de hasta 280 caracteres contados por code point (un emoji cuenta 1; se aplica en JS con clampMessage de src/lib/hire-limits.ts y el server valida igual). Urgencia: en modo por hora es un toggle fijo de +US$ 100 (el trabajo por hora se estima como un día, sin selector de días; el server fuerza 1 día y el mail dice "Urgencia: sí"); en modo por proyecto se cobra por día. Uso privado: toggle opcional "Uso privado" en las dos modalidades (PRICING.privateUse: +US$ 500 por proyecto, +US$ 200 por hora), con una línea propia en el resumen, sin descuento por tamaño y sumado al total y al anticipo. Por defecto (apagado) el dueño del sitio puede mostrar el trabajo en el portfolio y el código queda open source; prendido queda solo para el cliente. El server lo recibe como budget.privateUse (booleano estricto: si falta es false, así los presupuestos viejos siguen andando; cualquier otro valor da invalid_budget), lo recalcula, lo pone en el mail ("Uso privado: sí/no" y la línea "Uso privado" con su monto) y lo guarda dentro de hire_leads.budget_json (sin migración). El 15% de referidos sale del total, con el uso privado incluido. Horas: slider + campo numérico sincronizados (solo enteros, clampHours en pricing.ts: 1 a 6 por hora, 8 a 40 por proyecto; el server aplica el mismo recorte). Por proyecto: menos de 8 h es trabajo por hora y más de 40 h se negocia (la calculadora lo aclara). Tamaños: Chico 20 h, Mediano 30 h, Grande 40 h (los botones ponen esas horas). Descuento por tamaño sobre horas × tarifa: 8–19 h sin descuento, 20–29 h 15% (Chico), 30–39 h 20% (Mediano), 40 h 25% (Grande). Si se pasa o llega al tope aparece un modal (src/components/limit-modal.tsx): fondo carmesí, borde arcoíris de los CTA girando, solo el título "Máximo 280 caracteres" y un anillo con la cuenta; no se cierra solo: los primeros 5 s no se puede cerrar (Esc, tocar afuera y el botón no hacen nada; el botón muestra la cuenta y el foco queda atrapado en el diálogo), después "Entendido" se habilita y cierra con el botón, Esc o tocando afuera, devolviendo el foco al mensaje; pegar de nuevo reinicia la cuenta a 5; se centra con visualViewport (sirve con el teclado abierto). El resumen que se envía arranca por el total, honeypot hire_hp, Turnstile y rate limit. También acepta un POST de formulario normal (sin fetch) en /api/hire-contact. Para agentes de IA, public/llms.txt explica cómo usarlo (si forkeás, cambiá el dominio y el link de la agenda).
Montos de Contratar (admin) /contratar, /en/hire, PUT /api/pricing src/components/pricing-admin.tsx, src/lib/pricing-settings.ts, src/lib/pricing-db.ts, src/lib/pricing-api.ts, src/app/api/pricing/route.ts, src/app/contratar/page.tsx, migrations/0010_pricing_settings.sql Con sesión de admin y el modo edición prendido, arriba de la calculadora aparece un panel compacto "Montos · solo vos" (cerrado por defecto) para cambiar sin tocar código: tarifa por hora (baja, media, alta), presencial, viático y urgencia por día, uso privado (por proyecto y por hora), anticipo % y descuento por tamaño (Chico 20 h, Mediano 30 h, Grande 40 h). "Guardar" valida como el server y la calculadora cambia al toque; "Valores por defecto" vuelve a cargar los números de PRICING (hay que guardar). Se guarda en D1 pricing_settings (clave/valor, solo INSERT ... ON CONFLICT DO UPDATE, nunca se borra). Sin filas o sin la tabla, todo queda como PRICING; un valor guardado faltante o inválido vuelve al default de ese campo (y si los tres descuentos quedan incoherentes, vuelven los tres). Validación estricta (validatePricingPayload): todas las claves, enteros (no strings), tarifas 0 a 1000, presencial/viático/urgencia 0 a 10000, uso privado 0 a 100000, anticipo 0 a 100 %, descuentos 0 a 90 % y de menor a mayor (Chico ≤ Mediano ≤ Grande). La API responde 401 sin sesión, 403 si no sos admin (ADMIN_EMAILS) o si el modo edición está apagado (cookie lulox-edit-mode=off). /contratar se renderiza por request (force-dynamic, sin caché) y lee los montos de D1, así lo guardado se ve en la próxima carga; /api/hire-contact lee los mismos montos para el mail y hire_leads y nunca usa números del navegador. Las horas mín./máx., los tamaños y los topes de días quedan en código. public/llms.txt no menciona montos, así que sigue estático. Visitantes: nada nuevo. Antes de deployar hay que aplicar la migración 0010 (pnpm db:migrate:remote).
Mail del formulario /api/hire-contact src/lib/hire-contact.ts, src/app/api/hire-contact/route.ts Valida (280 caracteres, email), honeypot, rate limit (CONTACT_RL), recalcula el presupuesto en el server (incluido el uso privado, privateUse dentro de budget_json), guarda el envío en D1 (hire_leads, con el código de referido si es válido y activo) y manda el mail a CONTACT_TO (hola@lulox.com.ar, buzón en Zoho según los MX) con el visitante como reply-to. Se envía por Resend; el binding send_email de Email Routing es solo respaldo y solo entrega a su dirección verificada.
Textos e i18n todo el sitio src/i18n/messages.ts, src/i18n/locale-provider.tsx, src/lib/routes.ts Cada texto en es y en. El idioma sale de la URL (raíz = español, /en = inglés; ver "URLs en dos idiomas").
Temas todo el sitio src/components/providers/theme-provider.tsx, src/app/globals.css Claro/oscuro con la clase .dark. En el header va primero oscuro y después claro (como 🇦🇷 → 🇺🇸), con el activo resaltado. Sin preferencia guardada sigue al sistema (prefers-color-scheme): el script inline del <head> (PREFS_HEAD_SCRIPT en src/lib/prefs-shared.ts) lo aplica antes del primer render, sin flash, y si el sistema cambia con la página abierta se actualiza en vivo. Si elegís un tema a mano se guarda en la cookie lulox-theme y manda sobre el sistema. Los colores por defecto del esqueleto son variables CSS neutras arriba de globals.css. El admin los pisa desde el modo edición (D1 site_settings): el layout inyecta ese CSS al inicio del body.
Identidad y colores (admin) todo el sitio, PUT /api/settings src/lib/site-settings.ts, src/lib/site-settings-db.ts, src/components/admin-settings.tsx, migrations/0013_site_settings.sql Nombre, descripción, redes, agenda, avatar, foto principal, imagen para compartir y la paleta (claro y oscuro) viven en D1 site_settings (clave/valor, solo INSERT ... ON CONFLICT DO UPDATE). Se editan desde el panel "Personalizar" (pestañas Apariencia e Info personal). Sin filas se usan los defaults neutros de DEFAULT_SITE (nombre "Portfolio", acento azul). Antes de deployar hay que aplicar la migración 0013 (pnpm db:migrate:remote).
Footer / src/app/page.tsx (home-steal) El link "¡Robate este portfolio!" / "Steal this portfolio!" (ui.stealPortfolio en src/i18n/messages.ts). Apuntalo a tu fork o sacalo.

🖱️ Convención de cursor

Todo lo clickeable muestra cursor: pointer y lo deshabilitado cursor: not-allowed. Lo resuelve una regla global al final de src/app/globals.css (links, botones, roles ARIA clickeables, label[for], select, summary, inputs tipo range/checkbox/radio/submit/file/color y sus thumbs), así que componentes nuevos no necesitan nada extra. Excepciones a propósito: .sec-reorder-handle (grab), .avatar-hud (easter egg de doble click) y .md-acc-btn-edit (contenedor editable). Para chequearlo: con pnpm dev corriendo, pnpm audit:cursor recorre todas las rutas en español y bajo /en (home, blog y artículos, contratar, sobre mí, proyectos, contacto, tastes, rothko, privacidad, ingresar, 404) en claro/oscuro con Chrome headless y falla si algo clickeable no tiene el cursor correcto (AUDIT_URL, AUDIT_ROUTES, AUDIT_WAIT=load para apuntar a otro sitio). Para recorrerlo como admin, pasale una sesión local con AUDIT_COOKIE="better-auth.session_token=..." (solo contra tu preview local). Los campos de escribir (texto, número, búsqueda, fecha) mantienen el cursor de texto.

🗂️ Estructura del proyecto

├── src/
│   ├── app/            # rutas (páginas + api/), globals.css
│   ├── components/     # piezas de UI (calculadora, editor, nav, íconos...)
│   ├── content/        # site.ts (secciones, Turnstile), projects.ts (temas), post de ejemplo
│   ├── i18n/           # messages.ts (ES/EN), provider de idioma
│   └── lib/            # presupuestos, blog, auth, base de datos, lógica del formulario, junk-paths.ts (filtro de escaneos)
├── migrations/         # migraciones SQL de D1
├── scripts/            # scripts de seed y mantenimiento (scripts/sql: SQL manual, no son migraciones)
├── tests/              # tests unitarios (pnpm test)
├── public/             # imágenes, audio
├── worker.ts           # entrada del Worker (redirige www al dominio raíz, responde 404 liviano a los escaneos de bots y después OpenNext)
└── wrangler.jsonc      # config de Cloudflare: D1, R2, email, rate limit, vars

🦝 Identidad del sitio

El nombre, la descripción, las redes, la agenda, el avatar y la paleta no están en el repo. Viven en D1 (site_settings) y se editan en el modo admin. Un clon vacío arranca con defaults neutros (nombre "Portfolio", acento azul). El relato de Sobre mí y la bio del blog tienen textos genéricos en src/i18n/messages.ts; si D1 ya tiene site_copy para esas claves, gana la base.

💼 Contratar

La calculadora está en /contratar. Los montos por defecto están en PRICING y el admin los cambia en la página. El botón de agenda sale de site_settings (call_booking_url); si está vacío, no se muestra.

🤝 Contribuir

Issues y PRs bienvenidos. Simple:

  1. pnpm test && pnpm typecheck antes de pushear.
  2. Un cambio por PR, con una descripción clara.
  3. Si agregás o cambiás algo, actualizá los dos READMEs (README.md en español y README.en.md en inglés).
  4. Estilo: nada de rayas largas en textos que lea un visitante (ver AGENTS.md).

📄 Licencia

El código es GNU AGPL-3.0-or-later. Si corrés una versión modificada en un servidor, tenés que compartir su código con sus usuarios.

El contenido que subas (fotos, textos, posts, proyectos, audio, logos en public/) no entra en esa licencia: es del dueño del sitio. Las imágenes que ya están en public/ de este deploy son de quien las publicó. Reemplazalas si forkeás. Las fuentes vienen de Google Fonts con sus propias licencias (OFL).

About

Hire me, Blog, About me, Projects, Contact. Easy to manage from the phone.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages