Skip to content

Commit 7dbb86f

Browse files
committed
feat: Add project detail page with animated hero section, dynamic gallery, and image modal.
1 parent 3d1d833 commit 7dbb86f

File tree

2 files changed

+20
-100
lines changed

2 files changed

+20
-100
lines changed

resources/js/project-gallery.js

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
// 1. Importa GSAP al inicio del archivo. Ahora tienes acceso a él.
1+
// 1. Importa GSAP y ScrollTrigger.
22
import { gsap } from 'gsap';
3+
import { ScrollTrigger } from 'gsap/ScrollTrigger';
4+
5+
gsap.registerPlugin(ScrollTrigger);
36

47
// 2. Envolvemos toda la lógica en un listener para que se ejecute cuando el DOM esté listo.
58
document.addEventListener('DOMContentLoaded', function () {
@@ -13,10 +16,9 @@ document.addEventListener('DOMContentLoaded', function () {
1316

1417
const loader = document.getElementById('gallery-loader');
1518

16-
// 4. Obtenemos la URL de la API desde un atributo de datos que añadiremos en la vista Blade.
19+
// 4. Obtenemos la URL de la API desde un atributo de datos.
1720
const galleryUrl = galleryContainer.dataset.galleryUrl;
1821
if (!galleryUrl) {
19-
// Puede pasar si el proyecto no está guardado aún o no tiene galería asignada.
2022
console.warn('Galería detectada pero sin URL de datos. Omitiendo carga.');
2123
if(loader) loader.style.display = 'none';
2224
return;
@@ -25,13 +27,12 @@ document.addEventListener('DOMContentLoaded', function () {
2527
// --- Lógica de Renderizado con Animación ---
2628
const buildFullGallery = async () => {
2729
try {
28-
// 5. Usamos la URL que obtuvimos del atributo de datos.
2930
const response = await fetch(galleryUrl);
3031
if (!response.ok) throw new Error(`HTTP error!`);
3132
const images = await response.json();
3233

3334
if (!images || images.length === 0) {
34-
loader.innerHTML = '<p class="text-on-surface/60">Esta galería no tiene imágenes.</p>';
35+
if(loader) loader.innerHTML = '<p class="text-on-surface/60">Esta galería no tiene imágenes.</p>';
3536
return;
3637
}
3738

@@ -40,7 +41,8 @@ document.addEventListener('DOMContentLoaded', function () {
4041
images.forEach(image => {
4142
const link = document.createElement('a');
4243
link.href = image.src_full;
43-
link.className = 'gallery-item block mb-4 opacity-0';
44+
// Añadimos translate-y-4 para que la animación tenga desde dónde subir
45+
link.className = 'gallery-item block mb-4 opacity-0 translate-y-4';
4446

4547
const placeholder = document.createElement('div');
4648
placeholder.className = 'gallery-item-placeholder w-full overflow-hidden';
@@ -73,20 +75,26 @@ document.addEventListener('DOMContentLoaded', function () {
7375

7476
galleryContainer.appendChild(fragment);
7577

78+
// Disparamos evento custom por si alguien escucha
7679
window.dispatchEvent(new CustomEvent('gallery-ready'));
7780

78-
// GSAP ahora funciona porque fue importado al inicio del archivo.
81+
// Animación con ScrollTrigger
7982
gsap.to('.gallery-item', {
8083
opacity: 1,
8184
y: 0,
8285
duration: 0.8,
83-
ease: 'power3.out',
84-
stagger: 0.05,
86+
ease: 'power2.out',
87+
stagger: 0.08,
88+
scrollTrigger: {
89+
trigger: "#gallery-container",
90+
start: "top 85%", // Inicia cuando el top del contenedor está al 85% del viewport
91+
toggleActions: "play none none none"
92+
}
8593
});
8694

8795
} catch (error) {
8896
console.error('Error al construir la galería:', error);
89-
loader.innerHTML = '<p class="text-red-500">Error al cargar la galería.</p>';
97+
if(loader) loader.innerHTML = '<p class="text-red-500">Error al cargar la galería.</p>';
9098
}
9199
};
92100

resources/views/pages/projects/show.blade.php

Lines changed: 2 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ class="font-teko text-7xl md:text-9xl lg:text-[10rem] text-white uppercase leadi
123123
{{-- 2. SECCIÓN GALERÍA (sin cambios en su estructura) --}}
124124
<div class="bg-surface text-on-surface">
125125
<div class="container mx-auto py-16 px-4">
126-
<div id="gallery-container" class="columns-2 md:columns-3 gap-4"></div>
126+
<div id="gallery-container" data-gallery-url="{{ route('projects.gallery', $project) }}" class="columns-2 md:columns-3 gap-4"></div>
127127
</div>
128128
</div>
129129

@@ -316,98 +316,10 @@ function runCluster() {
316316
runCluster();
317317
}
318318
319-
320-
// ---------- GALERÍA ----------
321-
async function buildFullGallery() {
322-
if (!galleryContainer) return;
323-
try {
324-
const res = await fetch(`{{ route('projects.gallery', $project) }}`);
325-
if (!res.ok) throw new Error('HTTP ' + res.status);
326-
const images = await res.json();
327-
if (!images?.length) {
328-
galleryContainer.innerHTML = '<p class="text-on-surface/60">Esta galería no tiene imágenes.</p>';
329-
return;
330-
}
331-
const frag = document.createDocumentFragment();
332-
images.forEach(img => {
333-
const a = document.createElement('a');
334-
a.href = img.src_full;
335-
a.className = 'gallery-item block mb-4 opacity-0 translate-y-4';
336-
337-
const ph = document.createElement('div');
338-
ph.className = 'gallery-item-placeholder w-full overflow-hidden';
339-
if (img.height > 0) ph.style.aspectRatio = `${img.width} / ${img.height}`;
340-
341-
const picture = document.createElement('picture');
342-
picture.innerHTML = `
343-
<source type="image/avif" srcset="${img.srcset_avif}" sizes="(max-width: 768px) 50vw, 33vw">
344-
<source type="image/webp" srcset="${img.srcset_webp}" sizes="(max-width: 768px) 50vw, 33vw">
345-
<img src="${img.src_fallback}" alt="${img.alt}" loading="lazy" decoding="async"
346-
class="w-full h-full object-cover opacity-0 transition-opacity duration-500">
347-
`;
348-
const imgEl = picture.querySelector('img');
349-
imgEl.onload = () => imgEl.classList.remove('opacity-0');
350-
imgEl.onerror = () => {
351-
imgEl.onerror = null;
352-
imgEl.classList.remove('opacity-0');
353-
imgEl.style.backgroundColor = '#e5e7eb';
354-
imgEl.style.minHeight = '200px';
355-
};
356-
357-
ph.appendChild(picture);
358-
a.appendChild(ph);
359-
frag.appendChild(a);
360-
});
361-
galleryContainer.appendChild(frag);
362-
} catch (err) {
363-
console.error('Galería:', err);
364-
}
365-
}
366-
367-
function setupGalleryAnimation() {
368-
if (!galleryContainer) return;
369-
gsap.to('.gallery-item', {
370-
opacity: 1, y: 0, duration: 0.8, ease: 'power2.out', stagger: 0.08,
371-
scrollTrigger: {
372-
trigger: "#gallery-container",
373-
start: "top 85%",
374-
toggleActions: "play none none none"
375-
}
376-
});
377-
}
378-
379-
// ---------- MODAL ----------
380-
function setupModal() {
381-
const modal = document.getElementById('image-modal');
382-
if (!modal) return;
383-
const modalImage = document.getElementById('modal-image');
384-
const modalCloseButton = document.getElementById('modal-close-button');
385-
386-
const openModal = (url) => {
387-
modalImage.src = url;
388-
modal.classList.remove('opacity-0', 'pointer-events-none');
389-
document.body.style.overflow = 'hidden';
390-
};
391-
const closeModal = () => {
392-
modal.classList.add('opacity-0', 'pointer-events-none');
393-
document.body.style.overflow = '';
394-
};
395-
396-
galleryContainer?.addEventListener('click', e => {
397-
const link = e.target.closest('a.gallery-item');
398-
if (link) { e.preventDefault(); openModal(link.href); }
399-
});
400-
modalCloseButton.addEventListener('click', closeModal);
401-
modal.addEventListener('click', e => e.target === modal && closeModal());
402-
document.addEventListener('keydown', e => e.key === 'Escape' && closeModal());
403-
}
404-
405-
// ---------- RUN ----------
319+
// ---------- RUN HERO ANIMATIONS ----------
406320
splitTitle();
407321
applyInitialFonts();
408322
playHeroTL();
409-
setupModal();
410-
buildFullGallery().then(setupGalleryAnimation);
411323
});
412324
</script>
413325

0 commit comments

Comments
 (0)