// Joaquin Jeri — Editorial Grid layout
// Off-white bg · serif accents · masonry grid · fullscreen menu with submenus
const { useState, useEffect, useRef } = React;
// Viewport hook — `phone` < 600px, `tablet` < 960px
function useViewport() {
const get = () => {
if (typeof window === 'undefined') return { phone: false, tablet: false };
const w = window.innerWidth;
return { phone: w < 600, tablet: w < 960 };
};
const [vp, setVp] = useState(get);
useEffect(() => {
const onResize = () => setVp(get());
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return vp;
}
function V1Site({ tweaks }) {
const { PROJECTS, RETRATOS, BIO, CLIENTS, BOCA_SVG_URL, HOME_ORDER } = window.JJ_DATA;
const [page, setPage] = useState('home'); // home | project | portraits | info | contact
const [activeProject, setActiveProject] = useState(null);
const [menuOpen, setMenuOpen] = useState(false);
const [filter, setFilter] = useState('all');
const fontStack = tweaks.fontFamily === 'serif'
? `"Cormorant Garamond", "Times New Roman", serif`
: tweaks.fontFamily === 'mono'
? `"JetBrains Mono", ui-monospace, monospace`
: `"Neue Haas Grotesk Display Pro", "Helvetica Neue", Helvetica, Arial, sans-serif`;
const gap = tweaks.gridSpacing === 'dense' ? 2 : tweaks.gridSpacing === 'airy' ? 24 : 10;
// When viewing "all", use the curated HOME_ORDER (with portrait tiles mixed in).
// For category filters, fall back to filtering PROJECTS by cat.
const filtered = filter === 'all'
? (HOME_ORDER || []).map(entry => {
if (typeof entry === 'object' && entry.retratos) {
const n = entry.retratos;
return { id: `__retratos_${n}`, title: 'Portraits', client: 'RETRATOS', cat: 'portraits', cover: `images/retratos/retratos-${n}.jpg`, __nav: 'portraits' };
}
return PROJECTS.find(p => p.id === entry);
}).filter(Boolean)
: PROJECTS.filter(p => p.cat === filter);
const goProject = (p) => {
if (p.__nav) { navigate(p.__nav); return; }
setActiveProject(p); setPage('project'); window.scrollTo({ top: 0 });
};
const navigate = (p, f) => {
setPage(p);
setMenuOpen(false);
if (f !== undefined) setFilter(f);
window.scrollTo({ top: 0 });
};
return (
setMenuOpen(true)} onLogo={() => navigate('home', 'all')} bocaUrl={BOCA_SVG_URL} />
{menuOpen && (
setMenuOpen(false)}
navigate={navigate}
goProject={(p) => { setMenuOpen(false); goProject(p); }}
fontStack={fontStack}
projects={PROJECTS}
/>
)}
{page === 'home' && (
)}
{page === 'project' && activeProject && (
navigate('home', activeProject.cat)} />
)}
{page === 'portraits' && }
{page === 'info' && }
{page === 'contact' && }
);
}
function V1Header({ onMenu, onLogo, bocaUrl }) {
const { phone } = useViewport();
// Header height (used by spacer below): padding × 2 + content (~22px) + 1px border
const headerHeight = phone ? 51 : 59;
return (
Joaquín Jerí
Menu
{/* Spacer to push page content below the fixed header (so it doesn't slide under) */}
);
}
// Fullscreen menu — top categories + expandable submenus under Work and Editorial
function V1Menu({ onClose, navigate, goProject, fontStack, projects }) {
const { phone } = useViewport();
const [expanded, setExpanded] = useState(null); // 'work' | 'editorial' | null
const workProjects = projects.filter(p => p.cat === 'work');
const editorialProjects = projects.filter(p => p.cat === 'editorial');
const items = [
{ id: 'home', label: 'Index', sub: 'All projects', filter: 'all' },
{ id: 'home', label: 'Work', sub: 'Brand campaigns', filter: 'work', expandable: 'work', subList: workProjects },
{ id: 'home', label: 'Editorial', sub: 'Magazines & stories', filter: 'editorial', expandable: 'editorial', subList: editorialProjects },
{ id: 'portraits', label: 'Portraits', sub: '20 images' },
{ id: 'home', label: 'Prints', sub: 'Limited edition · for sale', filter: 'prints' },
{ id: 'home', label: 'Videos', sub: 'Motion & direction', filter: 'videos' },
{ id: 'info', label: 'About', sub: 'About & clients' },
{ id: 'contact', label: 'Contact', sub: 'Get in touch' },
];
return (
Joaquín Jerí — Index
Close ✕
{items.map((it, i) => (
navigate(it.id, it.filter)} style={{
flex: 1,
background: 'transparent', border: 0, color: '#f4f1ec',
padding: '14px 0', cursor: 'pointer', textAlign: 'left',
fontFamily: 'inherit',
}}>
{it.label}
{String(i + 1).padStart(2, '0')}{phone ? '' : ' — ' + it.sub}
{it.expandable && (
setExpanded(expanded === it.expandable ? null : it.expandable)}
aria-label={`Expand ${it.label}`}
style={{
background: 'transparent', border: '1px solid rgba(244,241,236,0.4)',
color: '#f4f1ec', cursor: 'pointer',
width: 28, height: 28, borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14, lineHeight: 1, padding: 0,
}}>
{expanded === it.expandable ? '−' : '+'}
)}
{/* Submenu for Work / Editorial */}
{it.expandable && expanded === it.expandable && (
{it.subList.map(p => (
goProject(p)}
style={{
background: 'transparent', border: 0, color: '#f4f1ec',
padding: '6px 0', cursor: 'pointer', textAlign: 'left',
fontFamily: 'inherit', opacity: 0.7,
fontSize: 15, letterSpacing: '-0.005em', width: '100%',
}}
onMouseEnter={(e) => e.currentTarget.style.opacity = 1}
onMouseLeave={(e) => e.currentTarget.style.opacity = 0.7}
>
↳
{p.title}
))}
)}
))}
Lima · Peru
joaquinjeri@gmail.com
© 2026
);
}
function V1Home({ projects, allProjects, onProject, gap, hover, filter, setFilter, onPage }) {
const { phone } = useViewport();
return (
{/* Hero — name set as a giant editorial mark */}
Photographer · Videographer · Lima, PE
JoaquínJerí
Brand campaigns, editorial, portrait and fine-food photography. Selected work from 2020—present.
{/* Filter bar — scrolls horizontally on phone */}
{[
{ id: 'all', label: 'All' },
{ id: 'work', label: 'Work' },
{ id: 'editorial', label: 'Editorial' },
{ id: 'portraits', label: 'Portraits', nav: 'portraits' },
{ id: 'prints', label: 'Prints' },
{ id: 'videos', label: 'Videos' },
].map(f => (
f.nav ? onPage(f.nav) : setFilter(f.id)} style={{
background: 'transparent', border: 0, padding: '4px 0', cursor: 'pointer',
color: filter === f.id ? '#1a1714' : 'rgba(26,23,20,0.45)',
borderBottom: filter === f.id ? '1px solid #1a1714' : '1px solid transparent',
letterSpacing: '0.18em', fontFamily: 'inherit',
flex: '0 0 auto', whiteSpace: 'nowrap',
}}>{f.label}
))}
{!phone && (
{projects.length} project{projects.length === 1 ? '' : 's'} · 2020—2026
)}
{/* Grid or empty state */}
{projects.length > 0 ? (
) : (
)}
);
}
// Empty state for Prints / Videos (no projects yet)
function V1Empty({ filter }) {
const copy = filter === 'prints'
? { kicker: 'Prints', title: 'Edition coming soon', body: 'A small run of limited-edition silver-gelatin and archival pigment prints is in production. Drop a line to be notified when the edition opens.' }
: filter === 'videos'
? { kicker: 'Videos', title: 'Motion archive coming soon', body: 'Direction and motion work — brand films, behind-the-scenes, and short documentaries — will land here through 2026.' }
: { kicker: 'Nothing here', title: 'No projects', body: 'Try another category.' };
return (
{copy.kicker} — In progress
{copy.title}
{copy.body}
Notify me →
);
}
// Masonry grid — distributes items into N columns by cumulative aspect-ratio
// height so columns end at roughly the same line. Order still flows left→right
// top→bottom; only "which column" is chosen by available space.
function V1AsymGrid({ projects, onProject, hover, gap }) {
const { phone, tablet } = useViewport();
const desktopCols = gap <= 4 ? 4 : gap >= 20 ? 2 : 3;
const cols = phone ? 1 : tablet ? 2 : desktopCols;
const colGap = phone ? Math.max(gap, 8) : gap;
const [ratios, setRatios] = useState({}); // id -> height/width ratio (after image loads)
const onImgLoad = (id, e) => {
const w = e.target.naturalWidth, h = e.target.naturalHeight;
if (!w || !h) return;
setRatios(r => r[id] === h / w ? r : { ...r, [id]: h / w });
};
// Build columns by always placing the next item in the currently-shortest column.
// Before an image's ratio is known, assume 1.25 (typical mixed portrait/landscape).
const columns = Array.from({ length: cols }, () => ({ items: [], total: 0 }));
projects.forEach((p) => {
const ratio = ratios[p.id] || 1.25;
let target = columns[0];
for (let i = 1; i < cols; i++) if (columns[i].total < target.total) target = columns[i];
target.items.push(p);
target.total += ratio;
});
return (
{columns.map((col, ci) => (
{col.items.map((p) => (
onProject(p)} hover={hover} onImgLoad={(e) => onImgLoad(p.id, e)} />
))}
))}
);
}
function V1Card({ p, onClick, hover, onImgLoad }) {
const [h, setH] = useState(false);
return (
setH(true)}
onMouseLeave={() => setH(false)}
style={{
position: 'relative', background: '#e8e3dc', border: 0, padding: 0,
cursor: 'pointer', overflow: 'hidden', width: '100%',
textAlign: 'left', fontFamily: 'inherit', display: 'block',
}}>
{p.client}{p.year ? ' — ' + p.year : ''}
{p.title}
);
}
// Project detail page — renders the actual full image set vertically
function V1Project({ project, allProjects, onProject, onBack }) {
const { phone } = useViewport();
// Navigate within the same category (Work next/prev stays in Work, etc.)
const sameCat = allProjects.filter(p => p.cat === project.cat);
const idx = sameCat.findIndex(p => p.id === project.id);
const next = sameCat[(idx + 1) % sameCat.length];
const prev = sameCat[(idx - 1 + sameCat.length) % sameCat.length];
const globalIdx = allProjects.findIndex(p => p.id === project.id);
return (
← Back
{String(globalIdx + 1).padStart(2, '0')} / {String(allProjects.length).padStart(2, '0')}
{project.client}{project.year ? ' · ' + project.year : ''} · {project.cat === 'work' ? 'Work' : 'Editorial'}
{project.title}
{project.images.length} image{project.images.length === 1 ? '' : 's'}
{/* Full image set, vertical stack, full-width */}
{project.images.map((src, i) => (
))}
onProject(prev)} style={{ background: 'transparent', border: 0, cursor: 'pointer', textAlign: 'left', padding: 0, fontFamily: 'inherit' }}>
← Previous
{prev.title}
onProject(next)} style={{ background: 'transparent', border: 0, cursor: 'pointer', textAlign: phone ? 'left' : 'right', padding: 0, fontFamily: 'inherit' }}>
Next →
{next.title}
);
}
// Portraits gallery — flat masonry of 20 portrait images, no project grouping
function V1Portraits({ retratos, gap }) {
const { phone, tablet } = useViewport();
const cols = phone ? 1 : tablet ? 2 : 3;
return (
A continuing series of {retratos.count} portraits.
{retratos.images.map((src, i) => (
))}
);
}
function V1Info({ bio, clients }) {
const { phone } = useViewport();
return (
About
{bio}
Based
Lima, Peru. Available worldwide.
Selected clients
{clients.map(c => {c} )}
Services
Brand campaigns · Editorial photography · Portraiture · Direction · Post-production
Contact
);
}
function V1Contact() {
return (
Get in touch
joaquinjeri@gmail.com
Lima · Peru
Available worldwide
@joaquinjerikukulis
);
}
function V1Footer({ onPage }) {
const { phone } = useViewport();
return (
© 2026 Joaquín Jerí
onPage('info')} style={{ background: 'transparent', border: 0, color: 'inherit', cursor: 'pointer', font: 'inherit', letterSpacing: '0.18em' }}>About
onPage('contact')} style={{ background: 'transparent', border: 0, color: 'inherit', cursor: 'pointer', font: 'inherit', letterSpacing: '0.18em' }}>Contact
);
}
window.V1Site = V1Site;