world-game/src/views/Portfolio.svelte

316 lines
8.1 KiB
Svelte
Raw Normal View History

<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'
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?.background_image ?? 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>
<section
class="portfolio golden-grid slide"
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) -->
<div class="portfolio-gallery" aria-hidden="true">
<GalleryAnimation images={currentProject.images_gallery} backgroundColor={currentProject.gallery_background_color} />
</div>
<!-- Mockup device (centre) -->
<div class="portfolio-mockup">
<img src={currentProject.mockup} alt={currentProject.title} />
</div>
<!-- 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.external_links 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 (extrême droite) -->
<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>
</section>
<style>
.portfolio {
2026-03-09 11:57:28 +01:00
background-color: #000;
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 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;
padding-right: 8rem;
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;
}
/* Mobile (≤ 700px) */
@media screen and (max-width: 700px) {
.portfolio-gallery {
grid-area: 1/1 / span 20 / span 20;
opacity: 0.3;
}
.portfolio-mockup {
grid-area: 3/4 / span 8 / span 14;
z-index: var(--z-content);
}
.portfolio-text {
grid-area: 9/3 / span 6 / span 16;
z-index: var(--z-content);
text-align: center;
}
.portfolio-nav {
grid-area: 17/4 / span 2 / span 14;
flex-direction: row;
justify-content: center;
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.portfolio-nav-item {
transition: none;
}
}
</style>