Galerie "Momente" zwischen Team und Preise

- Schwarze Section mit fünf Fotos aus dem Salon (Haarschnitte und Momente)
- Desktop: Section bleibt stehen, die Bildreihe läuft beim Scrollen
  seitlich durch, dazu ein Fortschrittsbalken
- Handy: horizontal wischen mit Einrasten, nächstes Bild lugt hervor
- Hover: Bild wird farbig und zoomt leicht; Menüpunkt "Galerie" ergänzt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBHsCnWNXAB2Le3gAEsivF
This commit is contained in:
Claude committed 2026-09-29 15:01:18 +00:00
1 parent 97a00e508a
commit 9e22ce616d
9 files changed
+121

No files matched your search

+6
View File
@@ -28,6 +28,7 @@ npm run preview # Build lokal ansehen
| Fotos (Hero, Menü) | `src/assets/photos/*.webp` | | Fotos (Hero, Menü) | `src/assets/photos/*.webp` |
| Logo | `src/assets/logo/emblem.svg` | | Logo | `src/assets/logo/emblem.svg` |
| Team-Porträts | `src/assets/team/*.webp` | | Team-Porträts | `src/assets/team/*.webp` |
| Galerie „Momente“ (Bilder & Titel) | `src/assets/gallery/*.webp`, `src/components/Gallery.tsx` |
| Schriftzug oben in der Mitte | `src/components/Nav.tsx` | | Schriftzug oben in der Mitte | `src/components/Nav.tsx` |
| Favicon, App-Icon, Vorschaubild zum Teilen | `public/` | | Favicon, App-Icon, Vorschaubild zum Teilen | `public/` |
| Impressum & Datenschutz | `impressum.html`, `datenschutz.html` | | Impressum & Datenschutz | `impressum.html`, `datenschutz.html` |
@@ -52,6 +53,10 @@ einfach `emblem.svg` ersetzen (ein `<path>` pro Form, `fill="currentColor"`).
abgelegt: `name.webp` (1000 px breit, Team-Liste) und `name-thumb.webp` (320 × 320, Fotoraster und abgelegt: `name.webp` (1000 px breit, Team-Liste) und `name-thumb.webp` (320 × 320, Fotoraster und
Preis-Thumbnails). Neue Porträts im selben Format ablegen und in `src/data/salon.ts` eintragen. Preis-Thumbnails). Neue Porträts im selben Format ablegen und in `src/data/salon.ts` eintragen.
**Galerie:** Schwarze Section zwischen Team und Preise. Auf dem Desktop bleibt sie stehen und die Bilder
laufen beim Scrollen seitlich durch, auf dem Handy wird gewischt. Neues Bild: als WebP (900 px breit) in
`src/assets/gallery/` ablegen und in `moments` in `Gallery.tsx` eintragen (Titel + Seitenverhältnis).
**Direktbuchung:** Jede Leistung trägt ihre Setmore-ID (`id`). Wird bei Setmore eine Leistung **Direktbuchung:** Jede Leistung trägt ihre Setmore-ID (`id`). Wird bei Setmore eine Leistung
neu angelegt, bekommt sie eine neue ID. Sie steht im Buchungslink der Leistung auf der neu angelegt, bekommt sie eine neue ID. Sie steht im Buchungslink der Leistung auf der
Setmore-Seite hinter `products=`. Setmore-Seite hinter `products=`.
@@ -61,6 +66,7 @@ Setmore-Seite hinter `products=`.
- [ ] Platzhalter in `impressum.html` und `datenschutz.html` ausfüllen (`[…]`) und rechtlich prüfen lassen - [ ] Platzhalter in `impressum.html` und `datenschutz.html` ausfüllen (`[…]`) und rechtlich prüfen lassen
- [ ] Domain in `.env` bei `VITE_SITE_URL` eintragen (für das Vorschaubild bei WhatsApp/Facebook) - [ ] Domain in `.env` bei `VITE_SITE_URL` eintragen (für das Vorschaubild bei WhatsApp/Facebook)
- [ ] Rollen im Team prüfen (`src/data/salon.ts`) - [ ] Rollen im Team prüfen (`src/data/salon.ts`)
- [ ] Einverständnis der Kunden auf den Galerie-Fotos einholen
- [ ] Einverständnis des Teams für die Verwendung der Fotos einholen (Porträts und Hero sind KI-Bilder - [ ] Einverständnis des Teams für die Verwendung der Fotos einholen (Porträts und Hero sind KI-Bilder
nach echten Fotos – vor der Veröffentlichung freigeben lassen) nach echten Fotos – vor der Veröffentlichung freigeben lassen)
+2
View File
@@ -2,6 +2,7 @@ import Lenis from 'lenis'
import { MotionConfig } from 'motion/react' import { MotionConfig } from 'motion/react'
import { useEffect } from 'react' import { useEffect } from 'react'
import { Footer } from './components/Footer' import { Footer } from './components/Footer'
import { Gallery } from './components/Gallery'
import { GridLines } from './components/GridLines' import { GridLines } from './components/GridLines'
import { Hero } from './components/Hero' import { Hero } from './components/Hero'
import { Intro } from './components/Intro' import { Intro } from './components/Intro'
@@ -36,6 +37,7 @@ export default function App() {
<Hero /> <Hero />
<Intro /> <Intro />
<TeamList /> <TeamList />
<Gallery />
<Prices /> <Prices />
</main> </main>
<Footer /> <Footer />
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+111
View File
@@ -0,0 +1,111 @@
import { motion, useScroll, useTransform } from 'motion/react'
import { useLayoutEffect, useRef, useState } from 'react'
import { cn } from '../lib/cn'
import { EASE_OUT_EXPO } from '../lib/motion'
import { GridLines } from './GridLines'
import { RevealLines } from './Reveal'
const photos = import.meta.glob<string>('../assets/gallery/*.webp', { eager: true, import: 'default' })
const photo = (name: string) => photos[`../assets/gallery/${name}.webp`]
// Seitenverhältnis (Breite/Höhe) der Dateien, damit nichts beschnitten wird
const moments = [
{ src: photo('platin'), title: 'Platin Buzz', ratio: 900 / 1119 },
{ src: photo('twists'), title: 'Twists', ratio: 900 / 1068 },
{ src: photo('crew'), title: 'Nach Feierabend', ratio: 900 / 1200 },
{ src: photo('fade-bart'), title: 'Skin Fade & Bart', ratio: 900 / 1078 },
{ src: photo('blond'), title: 'Blond Crop', ratio: 900 / 1200 },
]
function Moment({ m, i }: { m: (typeof moments)[number]; i: number }) {
return (
<figure className="group w-[78vw] shrink-0 snap-center md:w-auto">
<div
className="overflow-hidden bg-paper/5 md:h-[min(56svh,38rem)]"
style={{ aspectRatio: m.ratio } as React.CSSProperties}
>
<img
src={m.src}
alt={m.title}
loading="lazy"
decoding="async"
className="size-full object-cover grayscale-[35%] transition duration-700 ease-out-expo group-hover:scale-[1.04] group-hover:grayscale-0"
/>
</div>
<figcaption className="mt-3 flex items-baseline justify-between gap-4">
<span className="display text-sm md:text-base">{m.title}</span>
<span className="mini text-paper/50">{String(i + 1).padStart(2, '0')}</span>
</figcaption>
</figure>
)
}
/**
* Galerie auf schwarzem Grund. Desktop: die Section bleibt stehen und die Bilder
* laufen beim Scrollen seitlich durch. Handy: horizontal wischen.
*/
export function Gallery() {
const section = useRef<HTMLElement>(null)
const track = useRef<HTMLDivElement>(null)
const [distance, setDistance] = useState(0)
// Wie weit die Bildreihe über den Bildschirm hinausragt (nur Desktop)
useLayoutEffect(() => {
const el = track.current
if (!el) return
const desktop = window.matchMedia('(min-width: 768px)')
const measure = () => setDistance(desktop.matches ? Math.max(0, el.scrollWidth - window.innerWidth) : 0)
measure()
const ro = new ResizeObserver(measure)
ro.observe(el)
desktop.addEventListener('change', measure)
return () => {
ro.disconnect()
desktop.removeEventListener('change', measure)
}
}, [])
const { scrollYProgress } = useScroll({ target: section, offset: ['start start', 'end end'] })
const x = useTransform(scrollYProgress, [0, 1], [0, -distance])
return (
<section
id="galerie"
ref={section}
className="relative isolate bg-ink text-paper"
style={{ height: distance ? `calc(100svh + ${distance}px)` : undefined }}
>
<GridLines tone="dark" />
<div className={cn('flex flex-col overflow-hidden py-24 md:py-0', distance > 0 && 'md:sticky md:top-0 md:h-svh md:justify-center md:pt-16')}>
<div className="px-4 md:px-8">
<div className="mini flex justify-between gap-6 text-paper/60">
<span>Galerie</span>
<span>Aus dem Salon</span>
</div>
<RevealLines className="display mt-6 text-[clamp(2.1rem,5vw,4.5rem)]" lines={['Momente.']} />
</div>
<motion.div
ref={track}
style={{ x }}
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.15 }}
transition={{ duration: 1.1, ease: EASE_OUT_EXPO }}
className="mt-10 flex w-full snap-x snap-mandatory gap-3 overflow-x-auto px-4 [scrollbar-width:none] md:mt-12 md:w-max md:snap-none md:gap-4 md:overflow-visible md:px-8"
>
{moments.map((m, i) => (
<Moment key={m.title} m={m} i={i} />
))}
</motion.div>
{/* Fortschritt beim seitlichen Durchlaufen */}
{distance > 0 && (
<div className="mx-8 mt-8 hidden h-px bg-paper/15 md:block">
<motion.div style={{ scaleX: scrollYProgress }} className="h-full origin-left bg-paper" />
</div>
)}
</div>
</section>
)
}
+2
View File
@@ -5,11 +5,13 @@ import { cn } from '../lib/cn'
import { EASE_OUT_EXPO } from '../lib/motion' import { EASE_OUT_EXPO } from '../lib/motion'
import { scrollToHash, unlockScroll } from '../lib/scroll' import { scrollToHash, unlockScroll } from '../lib/scroll'
import { GridLines } from './GridLines' import { GridLines } from './GridLines'
import galleryCover from '../assets/gallery/twists.webp'
// Ein Bild oder – beim Team – ein 3 × 2 Raster aus allen Porträts // Ein Bild oder – beim Team – ein 3 × 2 Raster aus allen Porträts
const items = [ const items = [
{ href: '#preise', label: 'Preise', photos: [image('photos/hero-detail')] }, { href: '#preise', label: 'Preise', photos: [image('photos/hero-detail')] },
{ href: '#team', label: 'Team', photos: team.map((m) => m.thumb) }, { href: '#team', label: 'Team', photos: team.map((m) => m.thumb) },
{ href: '#galerie', label: 'Galerie', photos: [galleryCover] },
{ href: '#kontakt', label: 'Kontakt', photos: [image('photos/emblem-breit-900')] }, { href: '#kontakt', label: 'Kontakt', photos: [image('photos/emblem-breit-900')] },
] ]