world-game/src/views/Portfolio.svelte
isUnknown f3ce36b99c
All checks were successful
Deploy / Deploy to Production (push) Successful in 21s
portfolio : preload all project media on slide activation. related to #55
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 11:07:11 +02:00

585 lines
16 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script>
import { onMount } from 'svelte'
import { slides } from '@state/slides.svelte'
import { slideTo } from '@router'
import { createScrollNav } from '@composables/useScrollNav.svelte.js'
import GalleryAnimation from '@components/ui/GalleryAnimation.svelte'
import ResponsivePicture from '@components/ui/ResponsivePicture.svelte'
let { data } = $props()
// --- State ---
let currentIndex = $state(0)
let sectionEl = $state(null)
// --- Derived ---
const isActive = $derived(slides.active?.id === 'portfolio')
const projects = $derived(data?.projects ?? [])
const backgroundImage = $derived(data?.backgroundImage ?? null)
const currentProject = $derived(projects[currentIndex] ?? null)
// Capture du hash synchrone avant que tout effect puisse le modifier
const initialHash = window.location.hash.slice(1)
// --- Ancres ---
function setAnchor(index) {
const slug = projects[index]?.slug
if (!slug) return
history.replaceState(null, '', '#' + slug)
}
function clearAnchor() {
history.replaceState(null, '', window.location.pathname + window.location.search)
}
// Initialisation depuis l'ancre URL — une seule fois quand projects est prêt
$effect(() => {
if (projects.length === 0 || !initialHash) return
const idx = projects.findIndex(p => p.slug === initialHash)
if (idx > 0) currentIndex = idx
})
// --- Scroll nav composable ---
const nav = createScrollNav({
isActive: () => isActive,
onNavigate: (dir) => {
const next = dir === 'down'
? Math.min(currentIndex + 1, projects.length - 1)
: Math.max(currentIndex - 1, 0)
if (next === currentIndex) return false
currentIndex = next
setAnchor(next)
},
})
// --- Touch horizontal (mobile) ---
// Même pattern que Play : navigation entre projets, puis slide voisine aux bords.
// Le scroll vertical ne fait rien (touchmove bloqué).
const TOUCH_THRESHOLD = 50
const TOUCH_LOCK_MS = 650
let touchStartX = 0
let touchStartY = 0
let touchLocked = false
let touchLockTimer = null
function onTouchStart(e) {
if (!isActive) return
touchStartX = e.touches[0].clientX
touchStartY = e.touches[0].clientY
}
function onTouchEnd(e) {
if (!isActive) return
// Toujours intercepter : Portfolio gère lui-même toute la navigation touch
e.stopImmediatePropagation()
if (touchLocked) return
const deltaX = touchStartX - e.changedTouches[0].clientX
const deltaY = touchStartY - e.changedTouches[0].clientY
if (Math.abs(deltaX) < TOUCH_THRESHOLD || Math.abs(deltaY) > Math.abs(deltaX)) return
// Verrouillage anti-spam (même durée que le composable useScrollNav)
touchLocked = true
clearTimeout(touchLockTimer)
touchLockTimer = setTimeout(() => { touchLocked = false }, TOUCH_LOCK_MS)
if (deltaX > 0) {
// Swipe gauche → projet suivant, ou slide suivante si fin de liste
if (currentIndex < projects.length - 1) {
currentIndex++
setAnchor(currentIndex)
} else {
const next = slides.all[slides.activeIndex + 1]
if (next) slideTo(next.path)
}
} else {
// Swipe droite → projet précédent, ou slide précédente si début de liste
if (currentIndex > 0) {
currentIndex--
setAnchor(currentIndex)
} else {
const prev = slides.all[slides.activeIndex - 1]
if (prev) slideTo(prev.path)
}
}
}
// Bloque le scroll vertical natif quand la slide est active
function onTouchMove(e) {
if (!isActive) return
e.preventDefault()
}
// --- onMount ---
onMount(() => {
sectionEl?.addEventListener('wheel', nav.onWheel, { passive: false })
window.addEventListener('keydown', nav.onKeyDown)
window.addEventListener('touchstart', onTouchStart, { capture: true, passive: true })
window.addEventListener('touchend', onTouchEnd, { capture: true, passive: true })
window.addEventListener('touchmove', onTouchMove, { passive: false })
return () => {
sectionEl?.removeEventListener('wheel', nav.onWheel)
window.removeEventListener('keydown', nav.onKeyDown)
window.removeEventListener('touchstart', onTouchStart, { capture: true })
window.removeEventListener('touchend', onTouchEnd, { capture: true })
window.removeEventListener('touchmove', onTouchMove)
nav.destroy()
clearTimeout(touchLockTimer)
}
})
// --- Préchargement de tous les médias au premier passage ---
let preloaded = false
function preloadAll() {
for (const project of projects) {
if (project.mockup) new Image().src = project.mockup
if (project.mockupWebp) new Image().src = project.mockupWebp
if (project.galleryBackgroundImage) new Image().src = project.galleryBackgroundImage
for (const img of (project.imagesGallery ?? [])) {
if (img.src) new Image().src = img.src
if (img.webp) new Image().src = img.webp
}
}
}
// --- Effect: reset when slide deactivated ---
// wasActive évite que clearAnchor() s'exécute au montage initial
// (isActive est false avant l'initialisation des slides)
let wasActive = false
$effect(() => {
if (isActive) {
if (!preloaded) { preloaded = true; preloadAll() }
wasActive = true
} else if (wasActive) {
nav.reset()
touchLocked = false
clearTimeout(touchLockTimer)
currentIndex = 0
clearAnchor()
wasActive = false
}
})
</script>
<div class="portfolio-gallery mobile-only" aria-hidden="true">
<GalleryAnimation images={currentProject.imagesGallery} backgroundColor={currentProject.galleryBackgroundColor} backgroundImage={currentProject.galleryBackgroundImage} mode={currentProject.galleryAnimationMode} secondsPerImage={currentProject.secondsPerImage} />
</div>
<section
class="portfolio golden-grid"
style={backgroundImage ? `--background-image: url('${backgroundImage}')` : ''}
bind:this={sectionEl}
aria-label="Portfolio"
>
{#if currentProject}
<!-- Galerie animation (gauche desktop / plein écran mobile) -->
<div class="portfolio-gallery desktop-only" aria-hidden="true">
<GalleryAnimation images={currentProject.imagesGallery} backgroundColor={currentProject.galleryBackgroundColor} backgroundImage={currentProject.galleryBackgroundImage} mode={currentProject.galleryAnimationMode} secondsPerImage={currentProject.secondsPerImage} />
</div>
<!-- Mockup device (centre) -->
<div class="portfolio-mockup portfolio-mockup--{currentProject.galleryAnimationMode}">
<ResponsivePicture
src={currentProject.mockup}
srcset={currentProject.mockupSrcset}
webp={currentProject.mockupWebp}
sizes="(max-width: 700px) 90vw, 25vw"
alt={currentProject.title}
cls="portfolio-mockup-img"
/>
</div>
<img class="content-background mobile-only" src="/assets/img/BG GAME MOBILE.408a3a253492f65d39f8.png" alt="">
<!-- Infos projet (droite) -->
<div class="portfolio-text" aria-live="polite">
<h2>{currentProject.title}</h2>
<h3 class="portfolio-catchphrase gradient-blue">{@html currentProject.catchPhrase}</h3>
<div class="portfolio-description">{@html currentProject.description}</div>
<div class="portfolio-keywords">
{#each currentProject.keywords as kw}
<p><strong>{kw.label} :</strong> {kw.text}</p>
{/each}
</div>
<div class="portfolio-links">
{#each currentProject.externalLinks as link}
<a href={link.url} target="_blank" rel="noopener noreferrer" class="button with-icon earth-icon">{link.label}</a>
{/each}
</div>
</div>
{/if}
<!-- Sidebar navigation -->
<nav class="portfolio-nav" aria-label="Projets">
<ul role="list">
{#each projects as project, i}
<li class="portfolio-nav-item" class:active={i === currentIndex}>
<button
aria-current={i === currentIndex ? 'true' : undefined}
aria-label={project.title}
onclick={() => { currentIndex = i; setAnchor(i) }}
>
{#if project.thumbnail?.length}
<img src={project.thumbnail} alt="" />
{/if}
<span class="portfolio-nav-number" aria-hidden="true">{String(i + 1).padStart(2, '0')}</span>
</button>
</li>
{/each}
</ul>
</nav>
<!-- Arrows + counter -->
<div class="portfolio-arrows font-face-danzza" aria-label="Navigation projets">
<button
class="portfolio-arrow portfolio-arrow--up"
aria-label="Projet précédent"
disabled={currentIndex >= projects.length - 1}
onclick={() => { if (currentIndex < projects.length - 1) { currentIndex++; setAnchor(currentIndex) } }}
></button>
<button
class="portfolio-arrow portfolio-arrow--down"
aria-label="Projet suivant"
disabled={currentIndex === 0}
onclick={() => { if (currentIndex > 0) { currentIndex--; setAnchor(currentIndex) } }}
></button>
<output class="portfolio-counter">{String(currentIndex + 1).padStart(2, '0')}/{String(projects.length).padStart(2, '0')}</output>
</div>
</section>
<style>
.portfolio {
background-image: var(--background-image, none);
background-position: center;
background-repeat: no-repeat;
background-size: cover;
}
/* Desktop layout */
.portfolio-gallery {
grid-area: 1/1 / span 20 / span 7;
overflow: hidden;
}
.portfolio-mockup {
grid-area: 6/6 / span 10 / span 5;
z-index: var(--z-content);
display: flex;
align-items: center;
justify-content: center;
transform: translateX(2vw);
}
.portfolio-mockup--horizontal {
grid-area: 7/1 / span 10 / span 8;
transform: none;
}
.portfolio-mockup :global(picture),
.portfolio-mockup :global(.portfolio-mockup-img) {
width: 100%;
height: 100%;
object-fit: contain;
object-position: top;
}
.portfolio-text {
grid-area: 7/11 / span 6 / span 5;
z-index: var(--z-content);
text-align: left;
color: var(--color-text);
}
.portfolio-text h2 {
font-family: "Danzza Bold", sans-serif;
font-size: var(--font-size-title-main);
font-weight: 700;
text-transform: uppercase;
line-height: 1.1;
}
.portfolio-catchphrase {
font-family: "Danzza Medium", sans-serif;
font-size: var(--font-size-subtitle);
font-weight: 600;
color: var(--color-primary);
margin-bottom: 1rem;
}
.portfolio-description :global(p) {
font-family: "Danzza", sans-serif;
font-size: var(--font-size-subtitle);
line-height: 1.4;
letter-spacing: -2%;
}
.portfolio-keywords {
font-family: "Danzza", sans-serif;
font-size: 0.8125rem;
display: grid;
grid-template-columns: 1fr 1fr;
row-gap: 1rem;
margin: 3rem 0;
}
.portfolio-keywords p {
margin: 0;
}
.portfolio-links {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
/* Sidebar navigation */
.portfolio-nav ul {
list-style: none;
display: contents;
}
.portfolio-nav {
grid-area: 4/17 / span 14 / span 4;
margin-right: 7vw;
z-index: var(--z-content);
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
gap: 1rem;
}
.portfolio-nav-item:not(:last-child) {
margin-bottom: 1.5rem;
}
.portfolio-nav-item button {
display: flex;
align-items: flex-start;
gap: 0.5rem;
background: none;
border: none;
cursor: pointer;
transform: scale(1.25);
transition: transform 0.6s var(--ease-standard), opacity 0.6s var(--ease-standard);
}
.portfolio-nav-item:hover button {
transform: scale(1.25);
}
.portfolio-nav-item.active button {
transform: scale(1.75);
opacity: 1;
}
.portfolio-nav-number {
color: var(--color-text);
font-size: .5rem;
}
.portfolio-nav-item img {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 8px;
}
/* Arrows + counter */
.portfolio-arrows {
grid-area: 18/16 / span 2 / span 3;
z-index: var(--z-content);
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
color: white;
font-size: var(--font-size-caption);
letter-spacing: 1px;
}
.portfolio-arrow {
width: 13px;
min-width: 13px;
height: 13px;
background: none;
border: none;
cursor: pointer;
background-position: center;
background-size: contain;
background-repeat: no-repeat;
padding: 0;
opacity: 0.8;
transition: opacity 0.2s;
}
.portfolio-arrow:disabled {
opacity: 0.3;
cursor: default;
}
.portfolio-arrow--up {
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAsAAAARCAYAAAAL4VbbAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAACxSURBVChT7dExDgFBFMbxF4RKolC5ABGRuIIjqJQu4ATiDE6hcQKNhsJGi4YLqBQ6jfF/k53Js8YNfMkvb9/Mm8lmV5xzRTVsMDZrXkm+U8EQA9/ZFE7PYHND3Lc3T9DC1nciVxyg611dCKfq0EyhvWaE8LyAv3mFB/pYogFNO689dPDS4Qt2OOKOKjShnrHGPryG1YRmjo+91Kf7mf+wTWq4nNfwJ2NSw09kOPkuRuQNVDyfOxATXhEAAAAASUVORK5CYII=");
transform: rotate(180deg);
margin-right: 2px;
}
.portfolio-arrow--down {
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAsAAAARCAYAAAAL4VbbAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAACxSURBVChT7dExDgFBFMbxF4RKolC5ABGRuIIjqJQu4ATiDE6hcQKNhsJGi4YLqBQ6jfF/k53Js8YNfMkvb9/Mm8lmV5xzRTVsMDZrXkm+U8EQA9/ZFE7PYHND3Lc3T9DC1nciVxyg611dCKfq0EyhvWaE8LyAv3mFB/pYogFNO689dPDS4Qt2OOKOKjShnrHGPryG1YRmjo+91Kf7mf+wTWq4nNfwJ2NSw09kOPkuRuQNVDyfOxATXhEAAAAASUVORK5CYII=");
margin-right: 5px;
}
.portfolio-counter {
white-space: nowrap;
}
/* Mobile (≤ 700px) */
@media (max-width: 700px) {
.portfolio {
background: transparent;
width: 100vw;
height: auto;
overflow: hidden;
}
/* Gallery over background, under content */
.portfolio-gallery.mobile-only {
position: fixed;
opacity: 0.8;
z-index: 1;
transform: translateX(0vw);
width: 100vw;
}
.content-background {
position: fixed;
bottom: 0;
width: 100vw;
height: auto;
z-index: 5;
}
/* Mockup — centered top, behind text */
.portfolio-mockup {
grid-area: 4/4 / span 8 / span 14;
z-index: var(--z-content);
}
.portfolio-mockup--horizontal {
grid-area: 8/4 / span 8 / span 14;
scale: 1.2;
}
.portfolio-mockup :global(.portfolio-mockup-img) {
height: auto;
}
/* Text — over mockup, centered */
.portfolio-text {
grid-area: 9/4/span 8/span 14;
z-index: var(--z-content);
gap: .5rem;
text-align: center;
}
.portfolio-text h2 {
font-family: "Danzza Bold";
font-size: var(--font-size-title-section-mobile);
}
.portfolio-catchphrase {
font-size: var(--font-size-subtitle-mobile);
}
.portfolio-description :global(p) {
font-family: "Danzza Light", sans-serif;
font-size: var(--font-size-paragraph-mobile);
line-height: 1.4;
}
/* Hide keywords on mobile */
.portfolio-keywords {
display: none;
}
.portfolio-links {
margin-top: 1rem;
justify-content: center;
}
/* Nav thumbnails — horizontal, compact */
.portfolio-nav {
grid-area: 17/4 / span 1 / span 14;
flex-direction: row;
justify-content: center;
padding-right: 0;
height: 75px;
align-items: center;
overflow: hidden;
margin-right: 0;
}
.portfolio-nav-item:not(:last-child) {
margin-bottom: 0;
}
.portfolio-nav-item button {
flex-direction: column;
align-items: center;
transform: scale(1) !important;
transition: all .3s ease-in-out;
width: 3rem;
height: 3rem;
}
.portfolio-nav-item.active button {
width: 4rem;
height: 4rem;
}
.portfolio-nav-item:hover button {
transform: scale(1.1);
}
.portfolio-nav-item img {
width: 100%;
height: 100%;
}
.portfolio-nav-number {
display: none;
}
/* Arrows — bottom right */
.portfolio-arrows {
grid-area: 18/15 / span 1 / span 3;
font-size: var(--font-size-caption-mobile, 11px);
}
.portfolio-counter {
font-size: 0.8125rem;
}
}
/* Tablet (701px912px) */
@media (min-width: 701px) and (max-width: 912px) {
.portfolio-text h2 {
font-size: var(--font-size-title-main-tablet);
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.portfolio-nav-item {
transition: none;
}
}
</style>