// === Jairo Gallo — Photography Portfolio ===
// Concept: "The Photography Book"
const { useState, useEffect, useMemo, useCallback, useRef } = React;
// ── API helper ───────────────────────────────────────────────
async function apiFetch(path) {
try {
const r = await fetch(path);
if (!r.ok) return null;
return r.json();
} catch { return null; }
}
// ── Scroll reveal hook ───────────────────────────────────────
function useReveal(delay) {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (!window.IntersectionObserver) { el.classList.add("in"); return; }
const obs = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) { el.classList.add("in"); obs.unobserve(el); }
}, { threshold: 0.1, rootMargin: "0px 0px -40px 0px" });
obs.observe(el);
return () => obs.disconnect();
}, []);
return ref;
}
// ── Copy ─────────────────────────────────────────────────────
const COPY = {
en: {
nav: { work: "Work", collections: "Collections", about: "About", contact: "Contact" },
hero: { tag: "Photography from Miami, Florida.", cta: "View the work", scroll: "Scroll" },
work: { title: "Selected", em: "frames" },
collections: { title: "Bodies of", em: "work" },
view: "View collection",
frames: "frames",
bio: {
label: "About",
lines: [
"My interest in photography was not something from nothing, it was not born in the morning, it is something that I had always been excited about from a very young age, photography is an image saved at a precise moment taken with a camera.",
"Photography makes me perceive and transmit what I see and capture with the lens and the camera. An image endures in time because time stopped with the image."
],
sign: "J. Gallo",
stats: [
{ n: "18", l: "years shooting" },
{ n: "207", l: "published" },
{ n: "04", l: "collections" }
]
},
quote: "The best photographs are motivated by human feelings.",
quoteAttr: "Jairo Gallo",
contact: {
title: "Get in", em: "touch",
lead: "Available for editorial work, portrait sessions, and limited-edition prints.",
phone: "+1 (305) 607-9894",
city: "Miami, Florida, USA",
cta: "Message on WhatsApp"
},
foot: { role: "Photographer / Author", year: "MMXXVI", rights: "All rights reserved" }
},
es: {
nav: { work: "Obra", collections: "Colecciones", about: "Acerca", contact: "Contacto" },
hero: { tag: "Fotografia desde Miami, Florida.", cta: "Ver el trabajo", scroll: "Desplaza" },
work: { title: "Cuadros", em: "selectos" },
collections: { title: "Cuerpos de", em: "obra" },
view: "Ver coleccion",
frames: "imagenes",
bio: {
label: "Acerca",
lines: [
"Mi interés por la fotografía no fue algo de la nada, no nació por la mañana, es algo que siempre me había emocionado desde muy pequeño, fotografía es una imagen guardada en un preciso momento y tomada con una cámara.",
"La fotografía me hace percibir y transmitir lo que yo veo y capturo con el lente y la cámara. Una imagen perdura en el tiempo porque el tiempo se detuvo con la imagen."
],
sign: "J. Gallo",
stats: [
{ n: "18", l: "anos disparando" },
{ n: "207", l: "publicadas" },
{ n: "04", l: "colecciones" }
]
},
quote: "Las mejores fotografías están motivadas por sentimientos humanos.",
quoteAttr: "Jairo Gallo",
contact: {
title: "Hablemos", em: "",
lead: "Disponible para trabajo editorial, sesiones de retrato e impresiones de edicion limitada.",
phone: "+1 (305) 607-9894",
city: "Miami, Florida, USA",
cta: "Mensaje por WhatsApp"
},
foot: { role: "Fotografo / Autor", year: "MMXXVI", rights: "Todos los derechos reservados" }
}
};
const FALLBACK = {
en: [
{ id: "nature", name: "Nature", count: 56, year: "2012", desc: "Leaves, light, water, the green pulse of things.", seed: 2 },
{ id: "portraits", name: "Portraits", count: 42, year: "2019", desc: "Faces, gestures, the quiet between words.", seed: 0 },
{ id: "landscapes",name: "Landscapes", count: 38, year: "2015", desc: "Andean horizons and the silence of vast space.", seed: 1 },
{ id: "street", name: "Street", count: 71, year: "2010", desc: "City rhythm, strangers passing, accidental geometry.", seed: 3 }
],
es: [
{ id: "nature", name: "Naturaleza", count: 56, year: "2012", desc: "Hojas, luz, agua, el pulso verde de las cosas.", seed: 2 },
{ id: "portraits", name: "Retratos", count: 42, year: "2019", desc: "Rostros, gestos, el silencio entre palabras.", seed: 0 },
{ id: "landscapes",name: "Paisajes", count: 38, year: "2015", desc: "Horizontes andinos y el silencio del espacio.", seed: 1 },
{ id: "street", name: "Calle", count: 71, year: "2010", desc: "Ritmo urbano, transeuentes, geometria casual.", seed: 3 }
]
};
// ── Featured grid — adaptive editorial composition (1–6 cells) ─
function FeaturedGrid({ photos, labels, onOpen }) {
const count = Math.min(photos.length, 6);
if (count >= 1) {
return (
{photos.slice(0, 6).map((p, i) => (
onOpen && onOpen(i)}
onKeyDown={e => e.key === "Enter" && onOpen && onOpen(i)}>
))}
);
}
// Preview state — no real photos yet
return (
{labels.map((lb, i) => (
))}
);
}
// ── Collection card — cover with overlaid title ───────────────
function CollectionCard({ c, idx, cover, featured, t, onOpen }) {
const ref = useReveal();
return (
e.key === "Enter" && onOpen()}>
{cover
?

:
}
{c.year} · {c.count} {t.frames}
{c.name}
{t.view} →
);
}
// ── Collection overlay + lightbox ─────────────────────────────
function CollectionView({ collection, photos, lang, t, onClose }) {
const [lb, setLb] = useState(null); // lightbox index, or null
useEffect(() => {
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = ""; };
}, []);
useEffect(() => {
const fn = e => {
if (e.key === "Escape") { lb !== null ? setLb(null) : onClose(); }
else if (lb !== null && e.key === "ArrowRight") setLb(i => (i + 1) % photos.length);
else if (lb !== null && e.key === "ArrowLeft") setLb(i => (i - 1 + photos.length) % photos.length);
};
window.addEventListener("keydown", fn);
return () => window.removeEventListener("keydown", fn);
}, [lb, photos.length, onClose]);
const cur = lb !== null ? photos[lb] : null;
const next = () => setLb(i => (i + 1) % photos.length);
const prev = () => setLb(i => (i - 1 + photos.length) % photos.length);
return (
e.target === e.currentTarget && onClose()}>
{collection.name}
{photos.length} {t.frames}
{photos.length > 0
? photos.map((p, idx) => (
setLb(idx)}
onKeyDown={e => e.key === "Enter" && setLb(idx)}>
))
:
{lang === "en" ? "No photos in this collection yet." : "Sin fotos en esta coleccion aun."}
}
{cur && (
e.target === e.currentTarget && setLb(null)}>
{photos.length > 1 && (
)}
{cur.title && {cur.title}}
{lb + 1} / {photos.length}
{photos.length > 1 && (
)}
)}
);
}
// ── Root ─────────────────────────────────────────────────────
function App() {
const [lang, setLang] = useState(() => {
try { return localStorage.getItem("jg.lang") || "en"; } catch { return "en"; }
});
const [apiCollections, setApiCollections] = useState(null);
const [apiBio, setApiBio] = useState(null);
const [allPhotos, setAllPhotos] = useState([]);
const [featuredPhotos, setFeaturedPhotos] = useState([]);
const [openColl, setOpenColl] = useState(null);
const t = COPY[lang];
useEffect(() => {
try { localStorage.setItem("jg.lang", lang); } catch {}
}, [lang]);
useEffect(() => {
const fn = e => { if (e.data?.type === "__jg_lang") setLang(e.data.lang); };
window.addEventListener("message", fn);
return () => window.removeEventListener("message", fn);
}, []);
useEffect(() => { window.__jg_setLang = setLang; }, []);
useEffect(() => {
apiFetch("/api/collections.php").then(d => d && setApiCollections(d));
apiFetch("/api/bio.php").then(d => d && setApiBio(d));
apiFetch("/api/photos.php").then(d => {
if (d) {
setAllPhotos(d);
setFeaturedPhotos(d.filter(p => p.featured).slice(0, 6));
}
});
}, []);
const collections = useMemo(() => {
if (!apiCollections || !apiCollections.length) return FALLBACK[lang];
return apiCollections.map((c, i) => ({
id: c.slug || String(c.id),
apiId: c.id,
name: lang === "es" ? (c.name_es || c.name_en) : (c.name_en || c.name_es),
count: c.photo_count ?? 0,
year: c.year ? String(c.year) : "ongoing",
desc: lang === "es" ? (c.description_es || c.description_en || "")
: (c.description_en || c.description_es || ""),
seed: i
}));
}, [apiCollections, lang]);
const byCollection = useMemo(() => {
const map = {};
for (const p of allPhotos) {
const k = String(p.collection_id);
if (!map[k]) map[k] = [];
map[k].push(p);
}
return map;
}, [allPhotos]);
const openCollection = useCallback((c) => {
const k = String(c.apiId ?? c.id);
setOpenColl({ collection: c, photos: byCollection[k] || [] });
}, [byCollection]);
const openFeatured = useCallback(() => {
setOpenColl({
collection: { name: lang === "en" ? "Selected frames" : "Cuadros selectos" },
photos: featuredPhotos
});
}, [featuredPhotos, lang]);
const bioLines = useMemo(() => {
if (!apiBio) return t.bio.lines;
const raw = lang === "es" ? apiBio.bio_es : apiBio.bio_en;
if (!raw?.trim()) return t.bio.lines;
return raw.split(/\n+/).filter(Boolean);
}, [apiBio, lang, t.bio.lines]);
const quote = useMemo(() => {
if (!apiBio) return t.quote;
const raw = lang === "es" ? apiBio.quote_es : apiBio.quote_en;
return raw?.trim() || t.quote;
}, [apiBio, lang, t.quote]);
const heroPhoto = featuredPhotos[0] || null;
const portraitUrl = apiBio && apiBio.portrait_url ? apiBio.portrait_url : null;
// Live stats — real where we can, derived from loaded data
const liveStats = useMemo(() => ([
{ n: "18", l: t.bio.stats[0].l },
{ n: allPhotos.length ? String(allPhotos.length) : t.bio.stats[1].n, l: t.bio.stats[1].l },
{ n: String(collections.length).padStart(2, "0"), l: t.bio.stats[2].l },
]), [allPhotos.length, collections.length, t.bio.stats]);
const workRef = useReveal();
const bioRef = useReveal();
const contactRef = useReveal();
// Nav goes solid once the hero photo is scrolled past the bar
const heroRef = useRef(null);
const [navSolid, setNavSolid] = useState(false);
useEffect(() => {
const el = heroRef.current;
if (!el || !window.IntersectionObserver) { setNavSolid(true); return; }
const obs = new IntersectionObserver(
([e]) => setNavSolid(!e.isIntersecting),
{ rootMargin: "-64px 0px 0px 0px", threshold: 0 }
);
obs.observe(el);
return () => obs.disconnect();
}, []);
const featLabels = lang === "en"
? ["Highland morning","Portrait, Ana","Avenida 19","Fern study","Cordillera","Window light"]
: ["Manana andina","Retrato, Ana","Avenida 19","Estudio helecho","Cordillera","Luz de ventana"];
return (
<>
{/* NAV */}
{/* HERO — full-bleed photograph */}
{heroPhoto
?

:
}
{lang === "en" ? "Photographer" : "Fotografo"}
Jairo
Gallo.
{t.hero.tag}
{/* MARQUEE */}
Nature
Portraits
Landscapes
Street
Miami, FL
35mm
Digital
Film
Editorial
Print
Est. 2008
{/* duplicate */}
Nature
Portraits
Landscapes
Street
Miami, FL
35mm
Digital
Film
Editorial
Print
Est. 2008
{/* FEATURED WORK */}
{t.work.title} {t.work.em}
{featuredPhotos.length > 0 && (
{featuredPhotos.length} {t.frames}
)}
{/* COLLECTIONS */}
{t.collections.title} {t.collections.em}
{collections.map((c, i) => {
const k = String(c.apiId ?? c.id);
const cover = (byCollection[k] || [])[0];
const featured = collections.length % 2 === 1 && i === 0;
return (
openCollection(c)}
/>
);
})}
{/* BIO */}
{t.bio.label}
{portraitUrl
?

:
}
{liveStats.map((s, i) => (
{s.n}
{s.l}
))}
{bioLines.map((line, i) =>
{line}
)}
{t.bio.sign}
{/* PULL QUOTE */}
{/* CONTACT */}
{/* COLLECTION OVERLAY */}
{openColl && (
setOpenColl(null)}
/>
)}
{/* FOOTER */}
>
);
}
window.JGApp = App;