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 @@
+
+
+
+ {#each columns as col}
+
+
+
+ {#each col.images as src}
+
![]()
+ {/each}
+ {#each col.images as src}
+
![]()
+ {/each}
+
+
+ {/each}
+
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}
+
{@html currentProject.catchphrase}
+
{@html currentProject.description}
+
+ {#each currentProject.keywords as kw}
+
{kw.label} : {kw.text}
+ {/each}
+
+
+ {#each currentProject.external_links as link}
+
{link.label}
+ {/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: {