world-game/src/views/Portfolio.svelte
isUnknown e45380258b Feat: portfolio polish + font Danzza Light
- App.svelte : classe active sur la slide courante
- Portfolio : flèches PNG custom, <output> pour le compteur, transition gallery mobile avec délai à l'entrée seulement
- fonts.css : @font-face Danzza Light + classe .font-face-danzza-light corrigée

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 19:28:44 +01:00

504 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 { 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 ---
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"
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}
<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 {
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);
}
.portfolio-nav-item:hover button {
transform: scale(1.25);
}
.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>