From 0b563b46974a255b953095e5ea01ef727edb4a59 Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 5 Mar 2026 17:13:50 +0100 Subject: [PATCH] =?UTF-8?q?Feat:=20page=20Portfolio=20avec=20galerie=20ani?= =?UTF-8?q?m=C3=A9e,=20navigation=20par=20scroll/touch/clavier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- site/templates/portfolio.json.php | 34 ++- src/components/ui/GalleryAnimation.svelte | 50 +++++ src/composables/useScrollNav.svelte.js | 88 ++++++++ src/styles/gallery.css | 65 ++++++ src/styles/index.css | 1 + src/styles/variables.css | 3 + src/views/Expertise.svelte | 127 +++-------- src/views/Portfolio.svelte | 259 ++++++++++++++++++++-- vite.config.js | 3 +- 9 files changed, 505 insertions(+), 125 deletions(-) create mode 100644 src/components/ui/GalleryAnimation.svelte create mode 100644 src/composables/useScrollNav.svelte.js create mode 100644 src/styles/gallery.css diff --git a/site/templates/portfolio.json.php b/site/templates/portfolio.json.php index 7838aca..6d0e249 100644 --- a/site/templates/portfolio.json.php +++ b/site/templates/portfolio.json.php @@ -1,27 +1,23 @@ [ - 'title' => $page->intro_title()->value(), - 'text' => $page->intro_text()->value() - ], 'projects' => $page->children()->listed()->map(function($project) { return [ - 'title' => $project->title()->value(), - 'slug' => $project->slug(), - 'url' => $project->url(), - 'tagline' => $project->tagline()->value(), - 'description' => $project->description()->value(), - 'cover' => $project->cover()->toFile()?->url(), - 'cover_thumb' => $project->cover()->toFile()?->thumb(['width' => 100])->url(), - 'gallery' => $project->files()->filterBy('template', 'image')->limit(5)->map(function($img) { - return $img->url(); - })->values(), - 'impact' => $project->impact()->split(','), - 'category' => $project->category()->value(), - 'platforms' => $project->platforms()->split(','), - 'apple_link' => $project->apple_link()->value(), - 'android_link' => $project->android_link()->value() + 'title' => $project->title()->value(), + 'slug' => $project->slug(), + 'catchphrase' => $project->catchphrase()->value(), + 'description' => $project->description()->value(), + 'thumbnail' => $project->thumbnail()->toFile()?->url(), + 'images_gallery' => $project->images_gallery()->toFiles()->map(fn($f) => $f->url())->values(), + 'mockup' => $project->mockup()->toFile()?->url(), + 'keywords' => $project->keywords()->toStructure()->map(fn($i) => [ + 'label' => $i->label()->value(), + 'text' => $i->text()->value(), + ])->values(), + 'external_links' => $project->external_links()->toStructure()->map(fn($i) => [ + 'label' => $i->label()->value(), + 'url' => $i->url()->value(), + ])->values(), ]; })->values() ]; diff --git a/src/components/ui/GalleryAnimation.svelte b/src/components/ui/GalleryAnimation.svelte new file mode 100644 index 0000000..1d237b2 --- /dev/null +++ b/src/components/ui/GalleryAnimation.svelte @@ -0,0 +1,50 @@ + + + diff --git a/src/composables/useScrollNav.svelte.js b/src/composables/useScrollNav.svelte.js new file mode 100644 index 0000000..7257eee --- /dev/null +++ b/src/composables/useScrollNav.svelte.js @@ -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, + } +} diff --git a/src/styles/gallery.css b/src/styles/gallery.css new file mode 100644 index 0000000..cef463b --- /dev/null +++ b/src/styles/gallery.css @@ -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; + } +} diff --git a/src/styles/index.css b/src/styles/index.css index d437c36..c02597e 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -6,3 +6,4 @@ @import './buttons.css'; @import './cursor.css'; @import './utils.css'; +@import './gallery.css'; diff --git a/src/styles/variables.css b/src/styles/variables.css index c7ec9fd..3402246 100644 --- a/src/styles/variables.css +++ b/src/styles/variables.css @@ -56,6 +56,9 @@ --font-size-button-tablet: 12px; --font-size-caption-tablet: 11px; + /* Easing */ + --ease-standard: cubic-bezier(0.65, 0, 0.35, 1); + /* Font sizes — expertise items */ --font-size-expertise: 22px; --font-size-expertise-mobile: 18px; diff --git a/src/views/Expertise.svelte b/src/views/Expertise.svelte index d5108c1..8287746 100644 --- a/src/views/Expertise.svelte +++ b/src/views/Expertise.svelte @@ -1,20 +1,16 @@ -
-
-

{data?.title || 'Portfolio'}

-

Portfolio view - To be implemented

-
-
+
+ + + + + + + + {#if currentProject} + + + + +
+ {currentProject.title} +
+ + +
+

{currentProject.title}

+

{@html currentProject.catchphrase}

+
{@html currentProject.description}
+
+ {#each currentProject.keywords as kw} +

{kw.label} : {kw.text}

+ {/each} +
+ +
+ {/if} + + + +
diff --git a/vite.config.js b/vite.config.js index cdf1ec2..87b6cc7 100644 --- a/vite.config.js +++ b/vite.config.js @@ -14,7 +14,8 @@ export default defineConfig({ '@views': path.resolve(__dirname, 'src/views'), '@state': path.resolve(__dirname, 'src/state'), '@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: {