diff --git a/package.json b/package.json index 5c6f81e8..18cd59e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.89.11", + "version": "1.90.0", "private": true, "type": "module", "scripts": { diff --git a/src/app.css b/src/app.css index 580a1e19..188fec36 100644 --- a/src/app.css +++ b/src/app.css @@ -464,6 +464,101 @@ a:focus-visible { animation: none; } +/* ============================================ + HIKES TRANSITIONS + Cards + filter fly in/out vertically, clicked card morphs into the hero + map (cross-fade between thumbnail and map), photo strip slides in from + the right. Page chrome under the hero cross-fades so nothing snaps in + at transition end. Lives in app.css (not the page component) so the + rules are still loaded on the OLD side of a nav AWAY from /hikes. + ============================================ */ + +@keyframes hikes-fly-up { + from { transform: translateY(100vh); } + to { transform: translateY(0); } +} +@keyframes hikes-fly-down { + from { transform: translateY(0); } + to { transform: translateY(100vh); } +} +@keyframes hikes-root-fade-out { + from { opacity: 1; } + to { opacity: 0; } +} +@keyframes hikes-root-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes hike-strip-in-right { + from { transform: translateX(100vw); } + to { transform: translateX(0); } +} +@keyframes hike-strip-out-right { + from { transform: translateX(0); } + to { transform: translateX(100vw); } +} + +/* Only-child hike-fly-in pseudos (unpaired cards/filter on enter or exit): + * kill UA's default fade, switch blend mode so the custom fly animation + * shows clean motion against the rest of the page. */ +::view-transition-old(.hike-fly-in):only-child, +::view-transition-new(.hike-fly-in):only-child { + animation: none; + mix-blend-mode: normal; +} + +/* Paired (card ↔ hero): keep UA cross-fade so the card thumbnail dissolves + * into the hero map — otherwise the new image would just cover the old one + * and the thumbnail would vanish silently at t=0. Stretch the duration to + * match the group so the fade ends exactly when the morph does. */ +::view-transition-old(.hike-fly-in):not(:only-child), +::view-transition-new(.hike-fly-in):not(:only-child) { + animation-duration: 550ms; + animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Group (the morphing bbox) timing. */ +::view-transition-group(.hike-fly-in) { + animation-duration: 550ms; + animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Cards + filter rise from below the viewport on enter. */ +html.vt-enter-hikes::view-transition-new(.hike-fly-in):only-child { + animation: hikes-fly-up 700ms cubic-bezier(0.2, 0.7, 0.2, 1) both; +} + +/* Cards + filter drop off the bottom on exit. */ +html.vt-exit-hikes::view-transition-old(.hike-fly-in):only-child { + animation: hikes-fly-down 700ms cubic-bezier(0.4, 0.1, 0.4, 1) both; +} + +/* Photo strip slides in from the right when arriving at the detail page, + * and slides back out when returning to /hikes. Overrides the global root + * `animation: none` only for these hike-specific transitions. */ +html.vt-enter-hike-detail::view-transition-new(hike-strip):only-child { + animation: hike-strip-in-right 600ms cubic-bezier(0.2, 0.7, 0.2, 1) both; +} +html.vt-enter-hikes::view-transition-old(hike-strip):only-child { + animation: hike-strip-out-right 600ms cubic-bezier(0.4, 0.1, 0.4, 1) both; +} + +/* Cross-fade the rest of the page (root pseudo) during hike transitions so + * the destination's chrome — metrics + content + footer on the detail page, + * overview hero + credit on the index — phases in instead of snapping in + * at the end of the morph. Overrides the global rule above; scope keeps + * other routes' transitions on their existing instant-swap behavior. */ +html.vt-enter-hike-detail::view-transition-old(root), +html.vt-enter-hikes::view-transition-old(root), +html.vt-exit-hikes::view-transition-old(root) { + animation: hikes-root-fade-out 450ms ease-out both; +} +html.vt-enter-hike-detail::view-transition-new(root), +html.vt-enter-hikes::view-transition-new(root), +html.vt-exit-hikes::view-transition-new(root) { + animation: hikes-root-fade-in 450ms ease-out both; +} + /* ============================================ RECIPE GRID Responsive card grid used across recipe pages diff --git a/src/lib/components/hikes/HikeCard.svelte b/src/lib/components/hikes/HikeCard.svelte index 8ec4cd64..fee7c0da 100644 --- a/src/lib/components/hikes/HikeCard.svelte +++ b/src/lib/components/hikes/HikeCard.svelte @@ -53,7 +53,7 @@ const canton = $derived(resolveCanton(hike.canton)); - +
{#if hike.cover.src} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 8df96324..20f4df1f 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -60,16 +60,43 @@ onNavigate((navigation) => { if (!(/** @type {any} */ (document)).startViewTransition) return; - // Skip if staying within the same route group (recipe layout handles its own) - const fromGroup = navigation.from?.route.id?.split('/')[1] ?? ''; - const toGroup = navigation.to?.route.id?.split('/')[1] ?? ''; - if (fromGroup === toGroup) return; + const fromId = navigation.from?.route.id ?? ''; + const toId = navigation.to?.route.id ?? ''; + const fromGroup = fromId.split('/')[1] ?? ''; + const toGroup = toId.split('/')[1] ?? ''; + + // Skip same-group nav (recipe layout handles its own). Hikes is the + // exception: we want the card↔hero morph for /hikes ↔ /hikes/[slug]. + if (fromGroup === toGroup && fromGroup !== 'hikes') return; + + // Tag so scoped CSS can target each variant of hike nav: + // - vt-enter-hikes: arriving at /hikes from any other route → + // non-paired cards + filter bar fly up from below the viewport. + // (Covers / → /hikes AND back-nav /hikes/[slug] → /hikes, where + // the clicked card pairs with the hero and the rest fly in.) + // - vt-exit-hikes: leaving /hikes for any other route → + // non-paired cards + filter bar fly down off-screen. + // (Covers /hikes → / AND /hikes → /hikes/[slug], where the clicked + // card pairs into the hero and the rest fly out.) + // - vt-enter-hike-detail: arriving at a hike detail page (card → zoom). + const intoHikesIndex = toId === '/hikes' && fromId !== '/hikes'; + const outOfHikesIndex = fromId === '/hikes' && toId !== '/hikes'; + const intoHikeDetail = toId === '/hikes/[slug]'; return new Promise((resolve) => { - (/** @type {any} */ (document)).startViewTransition(async () => { + const root = document.documentElement; + if (intoHikesIndex) root.classList.add('vt-enter-hikes'); + if (outOfHikesIndex) root.classList.add('vt-exit-hikes'); + if (intoHikeDetail) root.classList.add('vt-enter-hike-detail'); + const transition = (/** @type {any} */ (document)).startViewTransition(async () => { resolve(); await navigation.complete; }); + transition.finished.finally(() => { + root.classList.remove('vt-enter-hikes'); + root.classList.remove('vt-exit-hikes'); + root.classList.remove('vt-enter-hike-detail'); + }); }); }); diff --git a/src/routes/hikes/+page.svelte b/src/routes/hikes/+page.svelte index f1cc9b94..c85d7a14 100644 --- a/src/routes/hikes/+page.svelte +++ b/src/routes/hikes/+page.svelte @@ -227,14 +227,20 @@
- + +
+ +
{#if visible.length === 0}

Keine Wanderung entspricht den aktuellen Filtern.

@@ -393,4 +399,12 @@ gap: 1rem; } } + + /* Wrapper sets the view-transition name/class on the filter bar so the + * same .hike-fly-in rules in app.css that animate the cards also + * animate this bar (fly-in on /hikes enter, fly-out on /hikes exit). */ + .filter-vt-box { + view-transition-name: hikes-filter-bar; + view-transition-class: hike-fly-in; + } diff --git a/src/routes/hikes/[slug]/+page.svelte b/src/routes/hikes/[slug]/+page.svelte index 78d0ebba..21e7b63d 100644 --- a/src/routes/hikes/[slug]/+page.svelte +++ b/src/routes/hikes/[slug]/+page.svelte @@ -29,8 +29,12 @@ const MdxComponent = $derived(data.MdxComponent as unknown as typeof import('svelte').SvelteComponent); const showPrivate = $derived(!!data.session?.user); - let track = $state(null); - let trackError = $state(null); + // Track is now loaded synchronously in +page.ts so the photo strip, + // elevation chart, and hero polyline are in the DOM on first paint — + // fixes both the brief layout shift when the strip used to pop in + // after an async fetch, and the /hikes → /hikes/[slug] view-transition + // slide-in (snapshot is captured before client effects run). + const track = $derived(data.track); // Toggled true once Leaflet's first tile batch paints. Drives the // fade-out of the SSR-rendered static hero so the static→interactive // handover is a soft cross-fade rather than a swap. @@ -86,24 +90,6 @@ return null; }); - $effect(() => { - let aborted = false; - fetch(hike.trackUrl) - .then((r) => { - if (!r.ok) throw new Error(`Track fetch failed: ${r.status}`); - return r.json() as Promise; - }) - .then((data) => { - if (!aborted) track = data; - }) - .catch((err: Error) => { - if (!aborted) trackError = err.message; - }); - return () => { - aborted = true; - }; - }); - // Active-stage scoping (multi-day hikes). When a stage is selected, the // metrics row + elevation view switch to that stage; "Alle Etappen" (null) // shows the whole route. Single-stage hikes never show the nav. @@ -286,13 +272,6 @@ /> - @@ -302,7 +281,7 @@ hike, so we lead with it. Title overlays at the bottom-left. A second HikeMap further down sticks in the scroll-area; both share state via the focusedImageStore so they animate together. --> -
+
{#if hike.heroMapUrlLight}