world-game/src/views/Portfolio.svelte

505 lines
14 KiB
Svelte
Raw Normal View History

<script>
import { onMount } from 'svelte'
import { slides } from '@state/slides.svelte'
import { navigation } from '@state/navigation.svelte'
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 ---
2026-03-09 11:57:28 +01:00
const isActive = $derived(slides.active?.id === 'portfolio')
const projects = $derived(data?.projects ?? [])
const backgroundImage = $derived(data?.backgroundImage ?? null)
const currentProject = $derived(projects[currentIndex] ?? null)
$effect(() => {
if (isActive) navigation.setScrolled(currentIndex > 0)
})
// 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)
},
})
// --- onMount ---
onMount(() => {
sectionEl?.addEventListener('wheel', nav.onWheel, { passive: false })
window.addEventListener('keydown', nav.onKeyDown)
return () => {
sectionEl?.removeEventListener('wheel', nav.onWheel)
window.removeEventListener('keydown', nav.onKeyDown)
nav.destroy()
}
})
// --- 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) {
wasActive = true
} else if (wasActive) {
nav.reset()
currentIndex = 0
clearAnchor()
wasActive = false
}
})
</script>
<div class="portfolio-gallery mobile-only" aria-hidden="true">
<GalleryAnimation images={currentProject.imagesGallery} backgroundColor={currentProject.galleryBackgroundColor} />
</div>
<section
class="portfolio golden-grid"
2026-03-09 11:57:28 +01:00
style={backgroundImage ? `--background-image: url('${backgroundImage}')` : ''}
bind:this={sectionEl}
ontouchstart={nav.onTouchStart}
ontouchend={nav.onTouchEnd}
aria-label="Portfolio"
>
<!-- Decorative vertical lines -->
<div class="vertical-line-start" aria-hidden="true"></div>
<div class="vertical-line vertical-line-col8" aria-hidden="true"></div>
<div class="vertical-line-center" aria-hidden="true"></div>
<div class="vertical-line vertical-line-col14" aria-hidden="true"></div>
<div class="vertical-line-end" aria-hidden="true"></div>
{#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} />
</div>
<!-- Mockup device (centre) -->
<div class="portfolio-mockup">
<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}
2026-03-09 11:57:28 +01:00
<a href={link.url} target="_blank" rel="noopener noreferrer" class="button 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 === 0}
onclick={() => { if (currentIndex > 0) { currentIndex--; setAnchor(currentIndex) } }}
></button>
<button
class="portfolio-arrow portfolio-arrow--down"
aria-label="Projet suivant"
disabled={currentIndex >= projects.length - 1}
onclick={() => { if (currentIndex < projects.length - 1) { 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 {
2026-03-09 11:57:28 +01:00
background-image: var(--background-image, none);
background-position: center;
background-repeat: no-repeat;
background-size: cover;
}
/* Custom vertical lines */
.vertical-line-col8 {
grid-area: 1/8 / span 20 / span 1;
}
.vertical-line-col14 {
grid-area: 1/14 / span 20 / span 1;
}
/* Desktop layout */
.portfolio-gallery {
grid-area: 1/1 / span 20 / span 7;
overflow: hidden;
}
.portfolio-mockup {
grid-area: 6/7 / span 10 / span 4;
z-index: var(--z-content);
display: flex;
align-items: center;
justify-content: center;
}
.portfolio-mockup :global(picture),
.portfolio-mockup :global(.portfolio-mockup-img) {
width: 100%;
height: 100%;
object-fit: contain;
}
.portfolio-text {
grid-area: 7/11 / span 6 / span 5;
z-index: var(--z-content);
text-align: left;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.75rem;
color: var(--color-text);
}
.portfolio-text h2 {
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);
}
.portfolio-description {
font-size: var(--font-size-subtitle);
line-height: 1.5;
opacity: 0.8;
}
.portfolio-keywords {
font-size: var(--font-size-caption);
opacity: 0.6;
}
.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: 1rem;
}
.portfolio-nav-item button {
display: flex;
align-items: flex-start;
gap: 0.5rem;
background: none;
border: none;
cursor: pointer;
transform: scale(0.85);
opacity: 0.5;
transition: transform 0.6s var(--ease-standard), opacity 0.6s var(--ease-standard);
}
2026-03-09 11:57:28 +01:00
.portfolio-nav-item:hover button {
transform: scale(1.25);
2026-03-09 11:57:28 +01:00
}
.portfolio-nav-item.active button {
transform: scale(1.5) translateX(-20%);
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;
}
/* Gallery over background, under content */
.portfolio-gallery.mobile-only {
position: fixed;
opacity: 0.8;
z-index: 1;
transform: translateX(0vw);
width: 100vw;
transition: all .3s ease-in-out;
}
:global(.slide.active .portfolio-gallery.mobile-only) {
transform: translateX(-10vw);
width: 120vw;
transition: all .3s ease-in-out .8s;
}
.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 :global(.portfolio-mockup-img) {
height: auto;
}
/* Text — over mockup, centered */
.portfolio-text {
grid-area: 8/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 {
font-family: "Danzza Light", sans-serif;
font-size: var(--font-size-paragraph-mobile);
font-weight: 600;
line-height: 1.34;
}
/* Hide keywords on mobile */
.portfolio-keywords {
display: none;
}
.portfolio-links {
margin-top: .5rem;
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;
}
.portfolio-nav-item.active button {
transform: scale(1.15);
}
.portfolio-nav-item:hover button {
transform: scale(1.1);
}
.portfolio-nav-item img {
width: 3.75rem;
height: 3.75rem;
}
.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>