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:
9 files changed
+121
No files matched your search
@@ -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,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 |
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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')] },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user