);
};
// ---------- Standalone-mode detection ----------
// Always render media slots as fixed (no drop zones, no edit tools).
const IS_STANDALONE = true;
// Resolve via window.__resources for the standalone bundle; fall back to
// project-relative paths when running unbundled.
const RES = (id, fallback) =>
(typeof window !== "undefined" && window.__resources && window.__resources[id]) || fallback;
const PROJECT_DEFAULTS = {
"odyssey": {
thumb: RES("odysseyThumb", "assets/odyssey-thumb.gif"),
caseStudy: [
{
kind: "boards",
n: "01",
label: { es: "PROPUESTA VISUAL 01", en: "VISUAL PROPOSAL 01" },
title: { es: "Línea épica moderna", en: "Modern epic line" },
caption: {
es: "Una dirección luminosa y monumental: tipografía blanca de gran peso sobre azul eléctrico, héroes a contraluz y un sistema de pósters por elemento —cielo, tierra y océano.",
en: "A luminous, monumental direction: heavy white type over electric blue, backlit heroes and a per-element poster system —sky, land and ocean.",
},
items: [
"assets/odyssey-l1-logo.png",
"assets/odyssey-l1-warrior.png",
{ triptych: [
{ src: "assets/odyssey-l1-sky.gif", caption: { es: "El Cielo", en: "The Sky" } },
{ src: "assets/odyssey-l1-land.gif", caption: { es: "La Tierra", en: "The Land" } },
{ src: "assets/odyssey-l1-ocean.gif", caption: { es: "El Agua", en: "The Water" } },
] },
],
},
{
kind: "boards",
n: "02",
label: { es: "PROPUESTA VISUAL 02", en: "VISUAL PROPOSAL 02" },
title: { es: "Línea mito clásico", en: "Classical myth line" },
caption: {
es: "Una dirección oscura y atemporal: logotipo serif grabado en piedra, bustos de mármol enfrentados y elementos de piedra, metal e hilo como medio narrativo de toda la odisea.",
en: "A dark, timeless direction: a serif logotype engraved in stone, facing marble busts and stone as the narrative medium of the whole odyssey.",
},
items: [
"assets/odyssey-l2-type.png",
"assets/odyssey-l2-busts.png",
{ stonePanels: [
{ src: "assets/odyssey-l2-stone-gif.gif", caption: { es: "La piedra como medio narrativo.", en: "Stone as a narrative medium." } },
{ src: "assets/odyssey-l2-coin.gif", caption: { es: "Los distintos objetos como símbolos del significado de la historia.", en: "The different objects as symbols of the story's meaning." } },
{ src: "assets/odyssey-l2-triton.gif", caption: { es: "Bestias y personajes como representación de los distintos capítulos del viaje.", en: "Beasts and characters as representations of the different chapters of the journey." } },
] },
],
},
],
},
"olfleck-plan": {
thumb: RES("olfleckThumb", "assets/olfleck-thumb.gif"),
posters: [
RES("olfleckDooh1", "assets/olfleck-dooh-01.png"),
RES("olfleckDooh2", "assets/olfleck-dooh-02.png"),
RES("olfleckDooh3", "assets/olfleck-dooh-03.png"),
RES("olfleckDooh4", "assets/olfleck-dooh-04.png"),
],
doohMupis: {
n: "02",
label: { es: "DOOH · MUPIS MUNICH", en: "DOOH · MUPIS MUNICH" },
title: { es: "Mupis en calle", en: "Street mupis" },
caption: {
es: "Las piezas llevadas a mupis digitales en el mobiliario urbano de Munich.",
en: "The pieces taken to digital mupis across Munich's street furniture.",
},
items: [
RES("olfleckMupi1", "assets/olfleck-mupi-01.jpg"),
RES("olfleckMupi2", "assets/olfleck-mupi-02.jpg"),
RES("olfleckMupi3", "assets/olfleck-mupi-03.jpg"),
RES("olfleckMupi4", "assets/olfleck-mupi-04.jpg"),
],
},
tramWrap: {
n: "03",
label: { es: "VINILADO · TRANVÍA MUNICH", en: "TRAM WRAP · MUNICH" },
title: { es: "Tranvía vinilado", en: "Wrapped tram" },
caption: {
es: "La campaña envolviendo un tranvía completo de la red MVG de Munich, del diseño técnico a su circulación por la ciudad.",
en: "The campaign wrapping a full tram on Munich's MVG network, from the technical artwork to it running through the city.",
},
items: [
RES("olfleckTram1", "assets/olfleck-tram-01.png"),
RES("olfleckTram2", "assets/olfleck-tram-02.jpg"),
RES("olfleckTram3", "assets/olfleck-tram-03.jpg"),
RES("olfleckTram4", "assets/olfleck-tram-04.jpg"),
RES("olfleckTram5", "assets/olfleck-tram-05.jpg"),
],
},
},
"love-button": {
thumb: RES("loveButtonHero", "assets/love-button-hero.png"),
hero: RES("loveButtonHero", "assets/love-button-hero.png"),
caseStudy: [
{
kind: "piece",
n: "01",
label: { es: "LA PIEZA", en: "THE PIECE" },
title: { es: "Feliz San Valentín", en: "Happy Valentine's" },
caption: {
es: "La pieza gráfica de la campaña: el botón para reclinar el asiento del coche convertido en una declaración de amor por San Valentín.",
en: "The campaign's print piece: Toyota's ISOFIX button turned into a Valentine's declaration of love.",
},
item: "assets/love-button-hero.png",
},
{
kind: "video",
n: "02",
label: { es: "CASE STUDY", en: "CASE STUDY" },
title: { es: "El caso", en: "The case" },
caption: {
es: "El vídeo de caso que cuenta la idea y los resultados de una campaña viral a coste cero.",
en: "The case-study video telling the idea and results of a zero-cost viral campaign.",
},
embed: "https://player.vimeo.com/video/1193990607",
},
],
},
"dia-del-padre": {
thumb: RES("diaPadreThumb", "assets/dia-padre-thumb.png"),
images: [
RES("diaPadre1", "assets/dia-padre-01.png"),
RES("diaPadre2", "assets/dia-padre-02.png"),
RES("diaPadre3", "assets/dia-padre-03.png"),
],
},
"charms": {
thumb: RES("breakfastThumb", "assets/breakfast-thumb.gif"),
caseStudy: [
{
kind: "video",
n: "01",
label: { es: "EL SPOT", en: "THE SPOT" },
title: { es: "Concepto", en: "Umbrella concept" },
caption: {
es: "Un spot para redes que presenta el concepto paraguas de la campaña.",
en: "A social spot that lands the campaign's umbrella concept.",
},
embed: "https://player.vimeo.com/video/1196767738?h=41bc578109",
poster: "assets/american-breakfast-cover.png",
},
{
kind: "posters",
n: "02",
label: { es: "GRÁFICA", en: "THE POSTERS" },
title: { es: "Posters", en: "AI-generated" },
caption: {
es: "Una serie de pósters generados con IA para mostrar a la familia y al producto.",
en: "A series of AI-generated posters showing the family and the product.",
},
items: [
RES("breakfastPoster1", "assets/breakfast-poster-01.png"),
RES("breakfastPoster2", "assets/breakfast-poster-02.png"),
RES("breakfastPoster3", "assets/breakfast-poster-03.png"),
RES("breakfastPoster4", "assets/breakfast-poster-04.png"),
],
},
{
kind: "guerrilla",
n: "03",
label: { es: "ACCIÓN DE GUERRILLA", en: "GUERRILLA ACTION" },
title: { es: "Acción Gimnasios", en: "Vulnerable moment" },
caption: {
es: "Una acción de guerrilla que tentaba a quienes más se saltan el desayuno —los que van al gym por las mañanas.",
en: "A guerrilla action tempting the people who skip breakfast the most —those who hit the gym in the mornings.",
},
items: [
RES("breakfastGuerrilla1", "assets/breakfast-guerrilla-01.gif"),
RES("breakfastGuerrilla2", "assets/breakfast-guerrilla-02.gif"),
RES("breakfastGuerrilla3", "assets/breakfast-guerrilla-03.gif"),
RES("breakfastGuerrilla4", "assets/breakfast-guerrilla-04.gif"),
RES("breakfastGuerrilla5", "assets/breakfast-guerrilla-05.gif"),
RES("breakfastGuerrilla6", "assets/breakfast-guerrilla-06.gif"),
],
},
],
},
"batalla-sabor": {
thumb: RES("batallaSaborThumb", "assets/batalla-sabor-thumb.gif"),
caseStudy: [
{
kind: "video",
n: "01",
label: { es: "SPOT TEASER", en: "TEASER SPOT" },
title: { es: "La batalla del sabor", en: "The flavor battle" },
caption: {
es: "Teaser de lanzamiento de la campaña: un duelo de chefs con estética de cartel de cine.",
en: "Campaign launch teaser: a chefs' duel with a movie-poster aesthetic.",
},
embed: "https://player.vimeo.com/video/1196442840",
poster: "assets/batalla-teaser-poster.png",
posterHasPlay: true,
},
{
kind: "digital",
n: "02",
label: { es: "BAJADA DIGITAL", en: "DIGITAL ROLLOUT" },
title: { es: "Piezas RRSS", en: "Character posters" },
caption: {
es: "Adaptación a redes: carteles de los chefs y jueces con tratamiento de cartel rasgado.",
en: "Social adaptation: torn-poster character pieces for the chefs and judges.",
},
items: [
"assets/batalla-lineup.png",
"assets/batalla-miriam.png",
"assets/batalla-peter.png",
"assets/batalla-kenza-isa.jpg",
"assets/batalla-kenza-chia.jpg",
],
},
{
kind: "social",
n: "03",
label: { es: "SPOTS SOCIAL", en: "SOCIAL SPOTS" },
title: { es: "Batallas Chef vs Influencers", en: "Pure flavor that grips" },
caption: {
es: "Píldoras verticales para redes",
en: "Vertical social cutdowns: 9:16 product shots carrying the campaign claim.",
},
items: [
"assets/batalla-sabor-social-01.gif",
"assets/batalla-sabor-social-02.gif",
"assets/batalla-social-04.gif",
"assets/batalla-social-05.gif",
"assets/batalla-social-06.gif",
"uploads/%C2%A1La%20Batalla%20Del%20Sabor%20ha%20terminado!%20%F0%9F%94%A5En%20un%20duelo%20e%CC%81pico%20%F0%9F%92%AA%20por%20el%20puro%20sabor%20que%20atrapa%2C%20%40cocina%20(1)-78268062.gif",
],
},
],
},
"rumbo-zero": {
thumb: RES("rumboZeroThumb", "assets/rumbo-zero-thumb.png"),
storyboard: [
{ src: "assets/rumbo-zero-sb-01.png", n: "01", title: "Apertura — Cuidar el planeta",
caption: "Tipografía cinética con máscaras de vídeo dentro de las palabras: «El mundo cada vez se preocupa más por cuidar del planeta»." },
{ src: "assets/rumbo-zero-sb-02.png", n: "02", title: "Toyota Woven City",
caption: "Vista aérea de la ciudad prototipo con el Monte Fuji; el logo aparece sobre un fondo de curvas de nivel." },
{ src: "assets/rumbo-zero-sb-03.png", n: "03", title: "El recorrido",
caption: "Un vehículo atraviesa el valle; el plano se fragmenta en seis franjas verticales que recomponen el paisaje." },
{ src: "assets/rumbo-zero-sb-04.png", n: "04", title: "Mario Picazo",
caption: "Presentación del invitado en el set del podcast, con el lockup Rumbo Zero · Toyota." },
{ src: "assets/rumbo-zero-sb-05.png", n: "05", title: "Cabecera del podcast",
caption: "Logo Rumbo Zero y el Toyota Mirai sobre césped. «Un podcast de Toyota»." },
{ src: "assets/rumbo-zero-sb-06.png", n: "06", title: "Actores del cambio",
caption: "Split entre un planeta vivo y un aerogenerador: «Somos muchos los actores del cambio que compartimos este propósito»." },
{ src: "assets/rumbo-zero-sb-07.png", n: "07", title: "Let's Go Beyond",
caption: "Transición de impacto sobre el cristal. «Bajo nuestro lema: Let's Go Beyond»." },
{ src: "assets/rumbo-zero-sb-08.png", n: "08", title: "Misión cero emisiones",
caption: "El Toyota Mirai sobre césped y el dato 0%: «En Toyota también. Viajamos hacia un futuro de zero emisiones»." },
],
},
"masters-iese": {
thumb: RES("mastersIeseThumb", "assets/masters-iese-thumb.gif"),
paper: true,
hero: "assets/masters-hero.png",
sections: [
{
label: { es: "DOOH", en: "DOOH" },
title: { es: "Pantallas en calle", en: "Out-of-home screens" },
caption: {
es: "La campaña adaptada a pantallas digitales de gran formato en Milán, Berlín, Bolonia, Hamburgo, París y Londres.",
en: "The campaign adapted to large-format digital screens across Milan, Berlin, Bologna and Hamburg.",
},
columns: [
[
{ src: "assets/masters-dooh-worldwide.png", label: "Milano · Worldwide views" },
{ src: "assets/masters-dooh-degree.png", label: "Our degree" },
{ src: "assets/masters-dooh-wallstreet.gif", label: "Wall Street" },
],
[
{ src: "assets/masters-dooh-suit.png", label: "Milano · New suit" },
{ src: "assets/masters-dooh-excel.png", label: "Berlin · Excel is a verb" },
{ src: "assets/masters-dooh-mindbusiness.png", label: "Mind your own business" },
],
[
{ src: "assets/masters-dooh-network.png", label: "Bologna · Network" },
{ src: "assets/masters-dooh-values.jpg", label: "Out of values" },
],
],
},
{
label: { es: "Banners", en: "Banners" },
title: { es: "Display digital", en: "Digital display" },
caption: {
es: "Kit completo de banners animados para medios pagados, declinando cada concepto a los distintos formatos.",
en: "A full kit of animated banners for paid media, declining each concept across the different formats.",
},
columns: [
[
{ src: "assets/masters-banner-v1.png", label: "Starships" },
{ src: "assets/masters-banner-01.png", label: "Worldwide views" },
{ src: "assets/masters-banner-04.png", label: "Ta-Da!" },
{ src: "assets/masters-banner-07.jpg", label: "Happiness" },
],
[
{ src: "assets/masters-banner-02.png", label: "New suit" },
{ src: "assets/masters-banner-05.png", label: "Our degree" },
{ src: "assets/masters-banner-08.jpg", label: "Heartquarters" },
{ src: "assets/masters-banner-10.png", label: "Boss's boss" },
{ src: "assets/masters-banner-11.png", label: "Risk-free" },
],
[
{ src: "assets/masters-banner-v2.png", label: "Unicorn" },
{ src: "assets/masters-banner-03.png", label: "Find you" },
{ src: "assets/masters-banner-06.png", label: "Network" },
{ src: "assets/masters-banner-09.jpg", label: "Out of values" },
],
],
},
],
},
"diccionario-moderno": {
thumb: RES("diccionarioModernoThumb", "assets/diccionario-moderno-thumb.png"),
embed2: "https://player.vimeo.com/video/1196616452",
embed2Poster: "assets/diccionario-poster.png",
caseStudy: [
{
kind: "video",
n: "01",
label: { es: "CASE STUDY", en: "CASE STUDY" },
title: { es: "El caso", en: "The case" },
caption: {
es: "El vídeo de caso: el reto de hablarle a la gen Z sin dejar de gustar a todos los públicos.",
en: "The case-study video: the challenge of speaking to Gen Z without losing the wider audience.",
},
embed: "https://player.vimeo.com/video/938127851",
},
{
kind: "video",
n: "02",
label: { es: "EL SPOT", en: "THE SPOT" },
title: { es: "Diccionario Moderno Español", en: "Modern Spanish Dictionary" },
caption: {
es: "El spot con Pablo Lluch: un diccionario de jerga moderna que traduce el menú al idioma de los jóvenes.",
en: "The spot with Pablo Lluch: a modern-slang dictionary translating the menu into young people's language.",
},
embed: "https://player.vimeo.com/video/1196616452",
poster: "assets/diccionario-poster.png",
},
],
},
"revolucion-lujo": {
thumb: RES("revolucionLujoThumb", "assets/revolucion-lujo-thumb.gif"),
titleImg: RES("yamaydoTitleGif", "uploads/Grabacio%CC%81n%20de%20pantalla%202026-05-25%20a%20las%2018.56.15-3fb008f9.gif"),
finalImg: RES("yamaydoFinal", "uploads/Grabacio%CC%81n%20de%20pantalla%202026-05-25%20a%20las%2018.50.18_1%20(1).gif"),
moodboard: [
{ src: RES("yamaydoMood1", "assets/yamaydo-mood-01.png"), x: 8, y: 4, w: 18, label: "01" },
{ src: RES("yamaydoMood2", "assets/yamaydo-mood-02.png"), x: 64, y: 2, w: 16, label: "02" },
{ src: RES("yamaydoMood3", "assets/yamaydo-mood-03.png"), x: 38, y: 22, w: 24, label: "03" },
{ src: RES("yamaydoMood4", "assets/yamaydo-mood-04.png"), x: 2, y: 36, w: 14, label: "04" },
{ src: RES("yamaydoMood5", "assets/yamaydo-mood-05.png"), x: 74, y: 28, w: 22, label: "05" },
{ src: RES("yamaydoMood6", "assets/yamaydo-mood-06.png"), x: 22, y: 52, w: 20, label: "06" },
{ src: RES("yamaydoMood7", "assets/yamaydo-mood-07.png"), x: 56, y: 58, w: 18, label: "07" },
{ src: RES("yamaydoMood8", "assets/yamaydo-mood-08.png"), x: 8, y: 74, w: 22, label: "08" },
{ src: RES("yamaydoMood9", "assets/yamaydo-mood-09.png"), x: 70, y: 80, w: 20, label: "09" },
],
},
};
const projectDefault = (id, slot) => {
const d = PROJECT_DEFAULTS[id];
return d && d[slot] ? d[slot] : null;
};
// `defaultSrc` is used when localStorage is empty — lets authors ship a baked-in
// thumbnail that the user can still override by dropping their own file.
const MediaSlot = ({ id, placeholder, className, defaultSrc, defaultType = "image" }) => {
const storeKey = `delafolla:media:${id}`;
const [media, setMedia] = useState(null); // { type: 'image'|'video', url }
const [over, setOver] = useState(false);
const [err, setErr] = useState(null);
const inputRef = useRef(null);
useEffect(() => {
try {
const raw = localStorage.getItem(storeKey);
if (raw) setMedia(JSON.parse(raw));
else if (defaultSrc) setMedia({ type: defaultType, url: defaultSrc, fromDefault: true });
} catch {}
}, [storeKey, defaultSrc]);
const ingest = (file) => {
if (!file) return;
const isImg = file.type.startsWith("image/");
const isVid = file.type.startsWith("video/");
if (!isImg && !isVid) {setErr("Solo imagen o vídeo");return;}
setErr(null);
const reader = new FileReader();
reader.onload = (e) => {
const next = { type: isImg ? "image" : "video", url: e.target.result };
setMedia(next);
try {localStorage.setItem(storeKey, JSON.stringify(next));}
catch {setErr("Archivo grande — visible solo esta sesión");}
};
reader.readAsDataURL(file);
};
const onDrop = (e) => {
e.preventDefault();e.stopPropagation();
setOver(false);
const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
if (f) ingest(f);
};
const openPicker = (e) => {
e && e.stopPropagation();
inputRef.current && inputRef.current.click();
};
const clear = (e) => {
e.stopPropagation();
setMedia(defaultSrc ? { type: defaultType, url: defaultSrc, fromDefault: true } : null);
setErr(null);
try {localStorage.removeItem(storeKey);} catch {}
};
// Standalone (exported) mode → render-only, no drop zone, no tools.
if (IS_STANDALONE) {
const initial = defaultSrc ? { type: defaultType, url: defaultSrc } : null;
const fixed = media || initial;
if (!fixed) return null;
return (
);
};
const ProjectDetail = ({ project, onClose, lang = "es" }) => {
const [posterLb, setPosterLb] = useState(null);
const rootRef = useRef(null);
const L = (o) => (o && typeof o === "object" && !Array.isArray(o)) ? (o[lang] || o.es) : o;
useEffect(() => {
const onKey = (e) => {if (e.key === "Escape") onClose();};
window.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {window.removeEventListener("keydown", onKey);document.body.style.overflow = "";};
}, [onClose]);
// Reveal/hide images with a soft fade as they enter and leave the viewport.
// Bidirectional: fades in on the way in, fades back out once scrolled past
// (in either direction). Scroll/rAF based so it works inside the detail's
// own scroll container, where IntersectionObserver proved unreliable.
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const reduce = window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const imgs = Array.from(root.querySelectorAll("img:not(.detail__home-img)"));
imgs.forEach((im) => im.classList.add("reveal-img"));
if (reduce) { imgs.forEach((im) => im.classList.add("is-in")); return; }
let ticking = false;
const update = () => {
ticking = false;
const vh = window.innerHeight || root.clientHeight;
const m = vh * 0.06; // stay fully visible until just past the edge
for (let i = 0; i < imgs.length; i++) {
const im = imgs[i];
const r = im.getBoundingClientRect();
const inView = r.bottom > -m && r.top < vh + m;
// Only reveal once the pixels are actually decoded — with lazy loading a
// visible-but-still-downloading image would otherwise fade an empty box
// and then pop in. Gating on `complete` makes the fade coincide with the
// image arriving, so lazy loads ease in instead of snapping.
if (inView && im.complete && im.naturalWidth > 0) im.classList.add("is-in");
else if (!inView) im.classList.remove("is-in");
}
};
const onScroll = () => {
if (!ticking) { ticking = true; requestAnimationFrame(update); }
};
root.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
// Lazy images collapse to 0px until they load, which shifts layout — re-run
// the visibility check whenever one loads (or errors) so it reveals correctly.
imgs.forEach((im) => {
im.addEventListener("load", onScroll);
im.addEventListener("error", onScroll);
});
// Double rAF so the opacity:0 base state paints once before is-in is added —
// otherwise above-the-fold images snap in with no transition.
const raf1 = requestAnimationFrame(() => requestAnimationFrame(update));
const t = setTimeout(update, 400);
return () => {
cancelAnimationFrame(raf1);
clearTimeout(t);
root.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
imgs.forEach((im) => {
im.removeEventListener("load", onScroll);
im.removeEventListener("error", onScroll);
});
};
}, [project && project.id]);
if (!project) return null;
return (
;
// ---------- Clients section (animated logo marquee, drag-to-fill) ----------
// Renders the 16 client slots twice for a seamless loop; pauses on hover so
// you can actually drop a logo onto a slot. Each slot persists by its id —
// the duplicate copy in the second half of the track shares the same id and
// therefore shows the same dropped image automatically.
// ---------- Clients marquee (static logos — not editable) ----------
const CLIENT_SLOT_COUNT = 11;
const ClientsSection = () => {
const half =