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` |
| Logo | `src/assets/logo/emblem.svg` |
| 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` |
| Favicon, App-Icon, Vorschaubild zum Teilen | `public/` |
| 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
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
neu angelegt, bekommt sie eine neue ID. Sie steht im Buchungslink der Leistung auf der
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
- [ ] Domain in `.env` bei `VITE_SITE_URL` eintragen (für das Vorschaubild bei WhatsApp/Facebook)
- [ ] 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
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 { useEffect } from 'react'
import { Footer } from './components/Footer'
import { Gallery } from './components/Gallery'
import { GridLines } from './components/GridLines'
import { Hero } from './components/Hero'
import { Intro } from './components/Intro'
@@ -36,6 +37,7 @@ export default function App() {
<Hero />
<Intro />
<TeamList />
<Gallery />
<Prices />
</main>
<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 { scrollToHash, unlockScroll } from '../lib/scroll'
import { GridLines } from './GridLines'
import galleryCover from '../assets/gallery/twists.webp'
// Ein Bild oder – beim Team – ein 3 × 2 Raster aus allen Porträts
const items = [
{ href: '#preise', label: 'Preise', photos: [image('photos/hero-detail')] },
{ 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')] },
]