Feat: page Portfolio avec galerie animée, navigation par scroll/touch/clavier
All checks were successful
Deploy / Deploy to Production (push) Successful in 18s

- Composable useScrollNav partagé entre Expertise et Portfolio (wheel/touch/clavier)
- GalleryAnimation : 3 colonnes CSS défilantes infinies avec décalage et delay
- Portfolio : golden grid, mockup centré, infos projet, sidebar vignettes navigables
- API portfolio.json.php alignée sur blueprint project.yml (catchphrase, images_gallery, mockup, keywords, external_links)
- Variable --ease-standard partagée dans variables.css
- Alias @composables ajouté dans vite.config.js
- Refactor Expertise pour utiliser le composable (comportement identique)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-03-05 17:13:50 +01:00
parent feb300f76e
commit 0b563b4697
9 changed files with 505 additions and 125 deletions

View file

@ -1,27 +1,23 @@
<?php <?php
$specificData = [ $specificData = [
'intro' => [
'title' => $page->intro_title()->value(),
'text' => $page->intro_text()->value()
],
'projects' => $page->children()->listed()->map(function($project) { 'projects' => $page->children()->listed()->map(function($project) {
return [ return [
'title' => $project->title()->value(), 'title' => $project->title()->value(),
'slug' => $project->slug(), 'slug' => $project->slug(),
'url' => $project->url(), 'catchphrase' => $project->catchphrase()->value(),
'tagline' => $project->tagline()->value(), 'description' => $project->description()->value(),
'description' => $project->description()->value(), 'thumbnail' => $project->thumbnail()->toFile()?->url(),
'cover' => $project->cover()->toFile()?->url(), 'images_gallery' => $project->images_gallery()->toFiles()->map(fn($f) => $f->url())->values(),
'cover_thumb' => $project->cover()->toFile()?->thumb(['width' => 100])->url(), 'mockup' => $project->mockup()->toFile()?->url(),
'gallery' => $project->files()->filterBy('template', 'image')->limit(5)->map(function($img) { 'keywords' => $project->keywords()->toStructure()->map(fn($i) => [
return $img->url(); 'label' => $i->label()->value(),
})->values(), 'text' => $i->text()->value(),
'impact' => $project->impact()->split(','), ])->values(),
'category' => $project->category()->value(), 'external_links' => $project->external_links()->toStructure()->map(fn($i) => [
'platforms' => $project->platforms()->split(','), 'label' => $i->label()->value(),
'apple_link' => $project->apple_link()->value(), 'url' => $i->url()->value(),
'android_link' => $project->android_link()->value() ])->values(),
]; ];
})->values() })->values()
]; ];

View file

@ -0,0 +1,50 @@
<script>
/**
* GalleryAnimation — animation CSS de galerie en 3 colonnes défilantes.
* @prop {string[]} images — URLs des images
* @prop {number} secondsPerImage — durée par image (défaut: 8s)
*/
let { images = [], secondsPerImage = 8 } = $props()
const columns = $derived.by(() => {
const count = images.length
const duration = count * secondsPerImage
const defs = [
{ offset: 0, delay: 0 },
{ offset: Math.floor(count / 3), delay: duration / 4 },
{ offset: 0, delay: duration / 2 },
]
return defs.map(({ offset, delay }) => ({
images: shiftImages(images, offset),
delay,
duration,
}))
})
function shiftImages(imgs, offset) {
if (!offset) return imgs
return [...imgs.slice(offset), ...imgs.slice(0, offset)]
}
</script>
<div
class="gallery-animation gallery-animation--vertical"
style="--gallery-duration: {columns[0]?.duration ?? 24}s"
>
{#each columns as col}
<div class="gallery-animation__column">
<div
class="gallery-animation__track"
style="animation-delay: -{col.delay}s"
>
<!-- Images × 2 pour le défilement infini -->
{#each col.images as src}
<img class="gallery-animation__image" {src} alt="" aria-hidden="true" loading="lazy" />
{/each}
{#each col.images as src}
<img class="gallery-animation__image" {src} alt="" aria-hidden="true" loading="lazy" />
{/each}
</div>
</div>
{/each}
</div>

View file

@ -0,0 +1,88 @@
/**
* createScrollNav composable partagé scroll/touch/clavier pour Expertise et Portfolio.
*
* @param {object} options
* @param {() => boolean} options.isActive getter réactif : la slide est-elle active ?
* @param {(dir: 'up'|'down') => false|void} options.onNavigate appelé pour naviguer ;
* retourner `false` si on est à la limite
* @param {object} [options.config]
* @param {number} [options.config.scrollLockMs=650]
* @param {number} [options.config.wheelDebounceMs=100]
* @param {number} [options.config.wheelThreshold=25]
* @param {number} [options.config.touchThreshold=50]
*/
export function createScrollNav({ isActive, onNavigate, config = {} }) {
const {
scrollLockMs = 650,
wheelDebounceMs = 100,
wheelThreshold = 25,
touchThreshold = 50,
} = config
let canScroll = $state(true)
let lockTimer = null
let scrollDelta = 0
let lastScrollAt = 0
let touchStartY = 0
function lock() {
canScroll = false
clearTimeout(lockTimer)
lockTimer = setTimeout(() => { canScroll = true }, scrollLockMs)
}
function tryNavigate(dir) {
const result = onNavigate(dir)
if (result !== false) lock()
}
function onWheel(e) {
e.preventDefault()
if (!isActive() || !canScroll) return
const now = Date.now()
if (now - lastScrollAt > wheelDebounceMs) scrollDelta = 0
lastScrollAt = now
scrollDelta += e.deltaY
if (Math.abs(scrollDelta) >= wheelThreshold) {
tryNavigate(scrollDelta > 0 ? 'down' : 'up')
scrollDelta = 0
}
}
function onTouchStart(e) {
touchStartY = e.touches[0].clientY
}
function onTouchEnd(e) {
if (!isActive() || !canScroll) return
const diff = touchStartY - e.changedTouches[0].clientY
if (Math.abs(diff) >= touchThreshold) tryNavigate(diff > 0 ? 'down' : 'up')
}
function onKeyDown(e) {
if (!isActive() || !canScroll) return
if (e.key === 'ArrowDown') { e.preventDefault(); tryNavigate('down') }
if (e.key === 'ArrowUp') { e.preventDefault(); tryNavigate('up') }
}
function reset() {
canScroll = true
clearTimeout(lockTimer)
lockTimer = null
scrollDelta = 0
}
function destroy() {
clearTimeout(lockTimer)
}
return {
get canScroll() { return canScroll },
onWheel,
onTouchStart,
onTouchEnd,
onKeyDown,
reset,
destroy,
}
}

65
src/styles/gallery.css Normal file
View file

@ -0,0 +1,65 @@
/* Gallery animation */
:root {
--gallery-gap: 8px;
--gallery-duration: 24s;
}
.gallery-animation {
width: 100%;
height: 100%;
overflow: hidden;
}
/* Vertical mode (Portfolio) */
.gallery-animation--vertical {
display: flex;
flex-direction: row;
gap: var(--gallery-gap);
}
.gallery-animation--vertical .gallery-animation__column {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.gallery-animation--vertical .gallery-animation__track {
display: flex;
flex-direction: column;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.gallery-animation--vertical .gallery-animation__column:nth-child(odd) .gallery-animation__track {
animation-name: galleryScrollDown;
animation-duration: var(--gallery-duration);
}
.gallery-animation--vertical .gallery-animation__column:nth-child(even) .gallery-animation__track {
animation-name: galleryScrollUp;
animation-duration: var(--gallery-duration);
}
.gallery-animation__image {
width: 100%;
height: auto;
display: block;
object-fit: contain;
}
@keyframes galleryScrollDown {
from { transform: translateY(0); }
to { transform: translateY(-50%); }
}
@keyframes galleryScrollUp {
from { transform: translateY(-50%); }
to { transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.gallery-animation__track {
animation: none;
}
}

View file

@ -6,3 +6,4 @@
@import './buttons.css'; @import './buttons.css';
@import './cursor.css'; @import './cursor.css';
@import './utils.css'; @import './utils.css';
@import './gallery.css';

View file

@ -56,6 +56,9 @@
--font-size-button-tablet: 12px; --font-size-button-tablet: 12px;
--font-size-caption-tablet: 11px; --font-size-caption-tablet: 11px;
/* Easing */
--ease-standard: cubic-bezier(0.65, 0, 0.35, 1);
/* Font sizes — expertise items */ /* Font sizes — expertise items */
--font-size-expertise: 22px; --font-size-expertise: 22px;
--font-size-expertise-mobile: 18px; --font-size-expertise-mobile: 18px;

View file

@ -1,20 +1,16 @@
<script> <script>
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { slides } from '@state/slides.svelte' import { slides } from '@state/slides.svelte'
import { createScrollNav } from '@composables/useScrollNav.svelte.js'
let { data } = $props() let { data } = $props()
// --- Constants --- // --- Constants ---
const SCROLL_LOCK_MS = 650 // ms before next scroll is accepted const PLAY_DELAY_MS = 300 // ms delay before starting playback on slide activation
const WHEEL_DEBOUNCE_MS = 100 // ms window to accumulate wheel delta const REV_TARGET_MIN = 0.1 // min revTarget to avoid pausing exactly at videoDuration
const WHEEL_THRESHOLD = 25 // accumulated px to trigger navigation
const TOUCH_THRESHOLD = 50 // px swipe distance to trigger navigation
const PLAY_DELAY_MS = 300 // ms delay before starting playback on slide activation
const REV_TARGET_MIN = 0.1 // min revTarget to avoid pausing exactly at videoDuration
// --- State --- // --- State ---
let currentItem = $state(0) let currentItem = $state(0)
let canScroll = $state(true)
let isReverse = $state(false) let isReverse = $state(false)
let offsetY = $state(0) let offsetY = $state(0)
let videoDuration = $state(0) let videoDuration = $state(0)
@ -27,11 +23,8 @@
let sectionEl = $state(null) let sectionEl = $state(null)
// --- Plain variables (not reactive, used in event listeners) --- // --- Plain variables (not reactive, used in event listeners) ---
let fwdTarget = null // forward video pause target (seconds) let fwdTarget = null
let revTarget = null // reverse video pause target (seconds) let revTarget = null
// Position in the forward video timeline.
// Updated from videoFwd.currentTime when going forward,
// and mirrored from videoRev.currentTime when going in reverse.
let currentFwdTime = 0 let currentFwdTime = 0
// --- Derived --- // --- Derived ---
@ -39,16 +32,27 @@
const items = $derived(data?.items ?? []) const items = $derived(data?.items ?? [])
const itemCount = $derived(items.length) const itemCount = $derived(items.length)
// segmentEnds[i] = timestamp where forward video pauses after reaching item i
const segmentEnds = $derived( const segmentEnds = $derived(
itemCount > 0 && videoDuration > 0 itemCount > 0 && videoDuration > 0
? Array.from({ length: itemCount }, (_, i) => videoDuration * (i + 1) / itemCount) ? Array.from({ length: itemCount }, (_, i) => videoDuration * (i + 1) / itemCount)
: [] : []
) )
// --- Scroll nav composable ---
const nav = createScrollNav({
isActive: () => isActive,
onNavigate: (dir) => {
if (segmentEnds.length === 0) return false
const prevItem = currentItem
const newItem = dir === 'down'
? Math.min(prevItem + 1, itemCount - 1)
: Math.max(prevItem - 1, 0)
if (newItem === prevItem) return false
navigate(dir, newItem)
},
})
// --- Center active item vertically in viewport --- // --- Center active item vertically in viewport ---
// Uses offsetTop which is NOT affected by CSS transforms
// Requires position:relative on .expertise-text (see CSS below)
function computeOffset() { function computeOffset() {
if (!textContainer || !itemEls[currentItem]) return if (!textContainer || !itemEls[currentItem]) return
const wh = window.innerHeight const wh = window.innerHeight
@ -58,8 +62,6 @@
} }
// --- Video control helpers --- // --- Video control helpers ---
// Capture current position from whichever video is playing, then stop both.
function stopActiveVideo() { function stopActiveVideo() {
if (videoFwd && !videoFwd.paused) { if (videoFwd && !videoFwd.paused) {
currentFwdTime = videoFwd.currentTime currentFwdTime = videoFwd.currentTime
@ -73,8 +75,6 @@
} }
} }
// Seek videoFwd to currentFwdTime and play until targetTime.
// If currently showing the reverse video, wait for the seek before switching.
function playForward(targetTime) { function playForward(targetTime) {
fwdTarget = targetTime fwdTarget = targetTime
if (videoFwd) videoFwd.currentTime = currentFwdTime if (videoFwd) videoFwd.currentTime = currentFwdTime
@ -88,8 +88,6 @@
} }
} }
// Seek videoRev to the mirror of currentFwdTime and play until targetTime.
// If currently showing the forward video, wait for the seek before switching.
function playReverse(targetTime) { function playReverse(targetTime) {
const revStart = Math.max(0, videoDuration - currentFwdTime) const revStart = Math.max(0, videoDuration - currentFwdTime)
revTarget = targetTime revTarget = targetTime
@ -104,37 +102,19 @@
} }
} }
// --- Navigate: move one item up or down --- // --- Navigate: move one item up or down (called by composable) ---
let scrollLockTimer = null // internal state for navigate() function navigate(direction, newItem) {
function navigate(direction) { const prevItem = currentItem
if (!canScroll || segmentEnds.length === 0) return currentItem = newItem
const prevItem = currentItem
const newItem = direction === 'down'
? Math.min(prevItem + 1, itemCount - 1)
: Math.max(prevItem - 1, 0)
if (newItem === prevItem) return // at boundary, ignore
canScroll = false
clearTimeout(scrollLockTimer)
scrollLockTimer = setTimeout(() => { canScroll = true }, SCROLL_LOCK_MS)
currentItem = newItem
if (direction === 'down' && videoFwd && !videoFwd.paused) { if (direction === 'down' && videoFwd && !videoFwd.paused) {
// Fast path: forward video already playing — just move the stop point
fwdTarget = segmentEnds[currentItem] fwdTarget = segmentEnds[currentItem]
} else if (direction === 'up' && videoRev && !videoRev.paused) { } else if (direction === 'up' && videoRev && !videoRev.paused) {
// Fast path: reverse video already playing — just move the stop point.
// Read live currentTime because currentFwdTime is stale during play.
const liveFwdPos = videoDuration - videoRev.currentTime const liveFwdPos = videoDuration - videoRev.currentTime
const segBoundary = segmentEnds[currentItem] const segBoundary = segmentEnds[currentItem]
const targetFwd = liveFwdPos > segBoundary ? segBoundary : 0 const targetFwd = liveFwdPos > segBoundary ? segBoundary : 0
revTarget = videoDuration - Math.max(targetFwd, REV_TARGET_MIN) revTarget = videoDuration - Math.max(targetFwd, REV_TARGET_MIN)
} else { } else {
// General case: stop whatever is playing, then start in the right direction
stopActiveVideo() stopActiveVideo()
if (direction === 'down') { if (direction === 'down') {
playForward(segmentEnds[currentItem]) playForward(segmentEnds[currentItem])
@ -148,38 +128,6 @@
requestAnimationFrame(() => { if (isActive) computeOffset() }) requestAnimationFrame(() => { if (isActive) computeOffset() })
} }
// --- Wheel capture (non-passive, attached in onMount) ---
let scrollDelta = 0 // internal state for onWheel()
let lastScrollAt = 0
function onWheel(e) {
e.preventDefault()
if (!isActive || !canScroll) return
const now = Date.now()
if (now - lastScrollAt > WHEEL_DEBOUNCE_MS) scrollDelta = 0
lastScrollAt = now
scrollDelta += e.deltaY
if (Math.abs(scrollDelta) >= WHEEL_THRESHOLD) {
navigate(scrollDelta > 0 ? 'down' : 'up')
scrollDelta = 0
}
}
// --- Touch ---
let touchStartY = 0 // internal state for touch handlers
function onTouchStart(e) { touchStartY = e.touches[0].clientY }
function onTouchEnd(e) {
if (!isActive || !canScroll) return
const diff = touchStartY - e.changedTouches[0].clientY
if (Math.abs(diff) >= TOUCH_THRESHOLD) navigate(diff > 0 ? 'down' : 'up')
}
// --- Keyboard ---
function onKeyDown(e) {
if (!isActive || !canScroll) return
if (e.key === 'ArrowDown') { e.preventDefault(); navigate('down') }
if (e.key === 'ArrowUp') { e.preventDefault(); navigate('up') }
}
// --- Playback lifecycle --- // --- Playback lifecycle ---
function initPlayback() { function initPlayback() {
setTimeout(() => { setTimeout(() => {
@ -190,9 +138,8 @@
videoFwd.currentTime = 0 videoFwd.currentTime = 0
currentFwdTime = 0 currentFwdTime = 0
isReverse = false isReverse = false
fwdTarget = dur / itemCount // = segmentEnds[0] fwdTarget = dur / itemCount
videoFwd.play().catch(() => {}) videoFwd.play().catch(() => {})
// Pre-seek reverse video to buffer the region needed for the first UP scroll
if (videoRev) videoRev.currentTime = Math.max(0, dur - dur / itemCount) if (videoRev) videoRev.currentTime = Math.max(0, dur - dur / itemCount)
requestAnimationFrame(() => computeOffset()) requestAnimationFrame(() => computeOffset())
} }
@ -216,15 +163,13 @@
currentItem = 0 currentItem = 0
offsetY = 0 offsetY = 0
isReverse = false isReverse = false
canScroll = true nav.reset()
clearTimeout(scrollLockTimer)
} }
// --- onMount: set up video listeners + wheel + resize --- // --- onMount ---
onMount(() => { onMount(() => {
sectionEl?.addEventListener('wheel', onWheel, { passive: false }) sectionEl?.addEventListener('wheel', nav.onWheel, { passive: false })
// Forward video: update currentFwdTime and pause when target is reached
const onFwdUpdate = () => { const onFwdUpdate = () => {
if (!videoFwd || videoFwd.paused || fwdTarget === null) return if (!videoFwd || videoFwd.paused || fwdTarget === null) return
if (videoFwd.currentTime >= fwdTarget) { if (videoFwd.currentTime >= fwdTarget) {
@ -235,7 +180,6 @@
} }
videoFwd?.addEventListener('timeupdate', onFwdUpdate) videoFwd?.addEventListener('timeupdate', onFwdUpdate)
// Reverse video: update currentFwdTime (mirrored) and pause when target is reached
const onRevUpdate = () => { const onRevUpdate = () => {
if (!videoRev || videoRev.paused || revTarget === null) return if (!videoRev || videoRev.paused || revTarget === null) return
if (videoRev.currentTime >= revTarget) { if (videoRev.currentTime >= revTarget) {
@ -249,16 +193,16 @@
const onResize = () => { if (isActive) computeOffset() } const onResize = () => { if (isActive) computeOffset() }
window.addEventListener('resize', onResize) window.addEventListener('resize', onResize)
window.addEventListener('orientationchange', onResize) window.addEventListener('orientationchange', onResize)
window.addEventListener('keydown', onKeyDown) window.addEventListener('keydown', nav.onKeyDown)
return () => { return () => {
sectionEl?.removeEventListener('wheel', onWheel) sectionEl?.removeEventListener('wheel', nav.onWheel)
videoFwd?.removeEventListener('timeupdate', onFwdUpdate) videoFwd?.removeEventListener('timeupdate', onFwdUpdate)
videoRev?.removeEventListener('timeupdate', onRevUpdate) videoRev?.removeEventListener('timeupdate', onRevUpdate)
window.removeEventListener('resize', onResize) window.removeEventListener('resize', onResize)
window.removeEventListener('orientationchange', onResize) window.removeEventListener('orientationchange', onResize)
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', nav.onKeyDown)
clearTimeout(scrollLockTimer) nav.destroy()
} }
}) })
@ -276,8 +220,8 @@
class="expertise golden-grid slide" class="expertise golden-grid slide"
aria-label="Expertise" aria-label="Expertise"
bind:this={sectionEl} bind:this={sectionEl}
ontouchstart={onTouchStart} ontouchstart={nav.onTouchStart}
ontouchend={onTouchEnd} ontouchend={nav.onTouchEnd}
> >
<!-- Video background (decorative) --> <!-- Video background (decorative) -->
<div class="expertise-bg" aria-hidden="true"> <div class="expertise-bg" aria-hidden="true">
@ -390,15 +334,14 @@
align-items: flex-start; align-items: flex-start;
} }
/* Sliding container — position:relative so el.offsetTop is relative to this element /* Sliding container */
(CSS transforms do NOT affect offsetTop, so computeOffset() is always accurate) */
.expertise-text { .expertise-text {
position: relative; position: relative;
width: 100%; width: 100%;
} }
.expertise-text, .expertise-item { .expertise-text, .expertise-item {
transition: all 0.6s cubic-bezier(0.65, 0, 0.35, 1); transition: all 0.6s var(--ease-standard);
} }
/* Individual text items */ /* Individual text items */

View file

@ -1,24 +1,257 @@
<script> <script>
import { fade } from 'svelte/transition' 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() 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 currentProject = $derived(projects[currentIndex] ?? null)
// --- 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
},
})
// --- 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 ---
$effect(() => {
if (!isActive) {
nav.reset()
currentIndex = 0
}
})
</script> </script>
<div class="portfolio" transition:fade> <section
<div class="portfolio__container"> class="portfolio golden-grid slide"
<h1>{data?.title || 'Portfolio'}</h1> bind:this={sectionEl}
<p>Portfolio view - To be implemented</p> ontouchstart={nav.onTouchStart}
</div> ontouchend={nav.onTouchEnd}
</div> 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} />
</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">{@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}
<a href={link.url} target="_blank" rel="noopener noreferrer" class="button">{link.label}</a>
{/each}
</div>
</div>
{/if}
<!-- Sidebar navigation (extrême droite) -->
<nav class="portfolio-nav" aria-label="Projets">
{#each projects as project, i}
<button
class="portfolio-nav-item"
class:active={i === currentIndex}
onclick={() => { currentIndex = i }}
>
<span class="portfolio-nav-number">{String(i + 1).padStart(2, '0')}</span>
<img src={project.thumbnail} alt={project.title} />
</button>
{/each}
</nav>
</section>
<style> <style>
.portfolio { .portfolio {
min-height: 100vh; background: #000;
padding: 8rem 2rem 4rem;
color: #fff;
} }
.portfolio__container { /* Custom vertical lines */
max-width: 1200px; .vertical-line-col8 {
margin: 0 auto; 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 6;
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-section);
line-height: 1.1;
}
.portfolio-catchphrase {
font-size: var(--font-size-subtitle);
color: var(--color-primary);
font-weight: normal;
}
.portfolio-description {
font-size: var(--font-size-paragraph-small);
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 {
grid-area: 4/17 / span 14 / span 4;
z-index: var(--z-content);
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
gap: 1rem;
}
.portfolio-nav-item {
display: flex;
align-items: center;
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.active {
transform: scale(1.25);
opacity: 1;
}
.portfolio-nav-number {
color: var(--color-text);
font-size: var(--font-size-caption);
}
.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> </style>

View file

@ -14,7 +14,8 @@ export default defineConfig({
'@views': path.resolve(__dirname, 'src/views'), '@views': path.resolve(__dirname, 'src/views'),
'@state': path.resolve(__dirname, 'src/state'), '@state': path.resolve(__dirname, 'src/state'),
'@router': path.resolve(__dirname, 'src/router'), '@router': path.resolve(__dirname, 'src/router'),
'@utils': path.resolve(__dirname, 'src/utils') '@utils': path.resolve(__dirname, 'src/utils'),
'@composables': path.resolve(__dirname, 'src/composables')
} }
}, },
server: { server: {