world-game/src/views/Portfolio.svelte
isUnknown e6062ba2e7
All checks were successful
Deploy / Deploy to Production (push) Successful in 20s
portfolio : ajustements mobile layout
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 19:10:05 +01:00

507 lines
14 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 { 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)
},
})
// --- 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} backgroundImage={currentProject.galleryBackgroundImage} mode={currentProject.galleryAnimationMode} />
</div>
<section
class="portfolio golden-grid"
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-center" 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} backgroundImage={currentProject.galleryBackgroundImage} mode={currentProject.galleryAnimationMode} />
</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;
}
/* 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 5;
z-index: var(--z-content);
display: flex;
align-items: center;
justify-content: center;
}
.portfolio-mockup--horizontal {
grid-area: 6/1 / span 10 / span 7;
}
.portfolio-mockup--horizontal :global(picture) {
transform: scale(1.25);
}
.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;
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 :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;
}
.portfolio-nav-item.active button {
transform: scale(1.15);
}
.portfolio-nav-item:hover button {
transform: scale(1.1);
}
.portfolio-nav-item img {
width: 3rem;
height: 3rem;
}
.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>