diff --git a/README.md b/README.md index 5c8e3ac..f629c4f 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ npm run preview # Build lokal ansehen | Was | Wo | | --- | --- | | Preise, Dauer, Leistungen, Öffnungszeiten, Team, Kontakt | `src/data/salon.ts` | -| Fotos (Hero, Über uns, Menü) | `src/assets/photos/*.webp` | +| Fotos (Hero, Menü) | `src/assets/photos/*.webp` | +| Logo | `src/assets/logo/emblem.svg` | | Team-Porträts | `src/assets/team/*.webp` | | Schriftzug oben in der Mitte | `src/components/Nav.tsx` | | Favicon, App-Icon, Vorschaubild zum Teilen | `public/` | @@ -36,8 +37,16 @@ und `hero-mobile.webp` (Hochformat-Ausschnitt fürs Handy). Die Headline steht u Schere und Kamm frei bleiben. Beim Austauschen alle drei Dateien ersetzen, den Handy-Ausschnitt so wählen, dass Gesicht und Hände darin liegen. `hero-detail.webp` (Hände mit Schere und Kamm) erscheint im Menü. -**Salon-Fotos:** „Über uns“ und der Menüpunkt „Kontakt“ zeigen das Logo an der Wand im Salon -(`emblem.webp`, `emblem-breit-900.webp`, aus den Fotos der Setmore-Seite). +**Logo:** Das Instagram-Profilbild (`src/assets/logo/instagram-profilbild.jpg`) wurde mit potrace +vektorisiert: +- `src/assets/logo/emblem.svg` – nur das Emblem, Farbe über `currentColor` (standardmäßig schwarz), für Druck & Co. +- `public/logo-profilbild.svg` – wie das Profilbild: quadratisch, weißes Emblem auf dunklem Grund. + +Auf der Website steht es in „Über uns“: Die Linien zeichnen sich beim Scrollen, danach folgt ein Lichtreflex +dem Mauszeiger (`src/components/Emblem.tsx`). Liegt irgendwann die Original-Vektordatei vom Designer vor, +einfach `emblem.svg` ersetzen (ein `` pro Form, `fill="currentColor"`). + +**Salon-Foto:** Der Menüpunkt „Kontakt“ zeigt das Logo an der Wand im Salon (`emblem-breit-900.webp`). **Team-Porträts:** Einheitliche Studio-Porträts (per Bild-KI aus den Originalfotos erstellt), je zweimal abgelegt: `name.webp` (1000 px breit, Team-Liste) und `name-thumb.webp` (320 × 320, Fotoraster und diff --git a/public/logo-profilbild.svg b/public/logo-profilbild.svg new file mode 100644 index 0000000..dd62daa --- /dev/null +++ b/public/logo-profilbild.svg @@ -0,0 +1 @@ +Amin B. Ahmadi – Logo diff --git a/src/assets/logo/emblem.svg b/src/assets/logo/emblem.svg new file mode 100644 index 0000000..cfc0323 --- /dev/null +++ b/src/assets/logo/emblem.svg @@ -0,0 +1 @@ + diff --git a/688583147_18598850812047347_2873932048780375583_n.jpg b/src/assets/logo/instagram-profilbild.jpg similarity index 100% rename from 688583147_18598850812047347_2873932048780375583_n.jpg rename to src/assets/logo/instagram-profilbild.jpg diff --git a/src/assets/photos/emblem.webp b/src/assets/photos/emblem.webp deleted file mode 100644 index 7f9e88a..0000000 Binary files a/src/assets/photos/emblem.webp and /dev/null differ diff --git a/src/components/Emblem.tsx b/src/components/Emblem.tsx new file mode 100644 index 0000000..bf35153 --- /dev/null +++ b/src/components/Emblem.tsx @@ -0,0 +1,99 @@ +import { useInView, useMotionValueEvent, useReducedMotion, useScroll } from 'motion/react' +import { useEffect, useId, useRef } from 'react' +import raw from '../assets/logo/emblem.svg?raw' +import { cn } from '../lib/cn' + +// Das Logo (Instagram-Profilbild, vektorisiert) liegt als SVG-Datei vor und wird hier inline gezeichnet, +// damit die Linien animiert und vom Licht angestrahlt werden können. +const viewBox = raw.match(/viewBox="([^"]+)"/)![1] +const [, , VB_W, VB_H] = viewBox.split(' ').map(Number) +const paths = [...raw.matchAll(/ m[1]) + +/** + * Logo-Emblem: + * - zeichnet sich beim ersten Erscheinen Linie für Linie, danach füllen sich die Linien + * - ein Lichtreflex wie auf Metall folgt dem Mauszeiger (ohne Maus: der Scroll-Position) + */ +export function Emblem({ className }: { className?: string }) { + const wrap = useRef(null) + const gradient = useRef(null) + const id = `emblem-light-${useId().replace(/[^\w-]/g, '')}` + const reduce = useReducedMotion() + const drawn = useInView(wrap, { once: true, amount: 0.4 }) + + // Lichtposition (0–1 relativ zum Logo): Ziel und aktueller, weich nachgezogener Wert + const light = useRef({ x: 0.3, y: 0.25, tx: 0.3, ty: 0.25, hover: false, raf: 0 }) + + const render = () => { + const l = light.current + l.x += (l.tx - l.x) * 0.12 + l.y += (l.ty - l.y) * 0.12 + gradient.current?.setAttribute('cx', String(l.x * VB_W)) + gradient.current?.setAttribute('cy', String(l.y * VB_H)) + const settled = Math.abs(l.tx - l.x) < 0.001 && Math.abs(l.ty - l.y) < 0.001 + l.raf = settled ? 0 : requestAnimationFrame(render) + } + const moveLight = (x: number, y: number) => { + const l = light.current + l.tx = x + l.ty = y + if (reduce) { + l.x = x + l.y = y + render() + } else if (!l.raf) l.raf = requestAnimationFrame(render) + } + + const { scrollYProgress } = useScroll({ target: wrap, offset: ['start end', 'end start'] }) + useMotionValueEvent(scrollYProgress, 'change', (p) => { + if (!light.current.hover) moveLight(0.1 + 0.8 * p, 0.15 + 0.5 * p) + }) + + useEffect(() => { + const l = light.current + return () => cancelAnimationFrame(l.raf) + }, []) + + const onPointerMove = (e: React.PointerEvent) => { + const r = wrap.current!.getBoundingClientRect() + light.current.hover = true + moveLight((e.clientX - r.left) / r.width, (e.clientY - r.top) / r.height) + } + const onPointerLeave = () => { + light.current.hover = false + const p = scrollYProgress.get() + moveLight(0.1 + 0.8 * p, 0.15 + 0.5 * p) + } + + return ( +
+ + + + + + + + + {/* Konturen für die Zeichen-Animation */} + + {/* Gefüllte Linien mit Lichtreflex */} + + {paths.map((d, i) => ( + + ))} + + +
+ ) +} diff --git a/src/components/Intro.tsx b/src/components/Intro.tsx index aa5fa6f..496d178 100644 --- a/src/components/Intro.tsx +++ b/src/components/Intro.tsx @@ -1,15 +1,15 @@ -import { image, salon } from '../data/salon' -import { FadeIn, ImageReveal, RevealLines } from './Reveal' +import { salon } from '../data/salon' +import { Emblem } from './Emblem' +import { FadeIn, RevealLines } from './Reveal' export function Intro() { return (
- + {/* Logo wie auf Instagram: helle Linien auf dunklem Grund */} +
+ +
diff --git a/src/components/Reveal.tsx b/src/components/Reveal.tsx index c20ca53..3c3ea1e 100644 --- a/src/components/Reveal.tsx +++ b/src/components/Reveal.tsx @@ -1,6 +1,5 @@ import { motion, type Variants } from 'motion/react' import type { ReactNode } from 'react' -import { cn } from '../lib/cn' import { EASE_OUT_EXPO } from '../lib/motion' const tags = { h1: motion.h1, h2: motion.h2, h3: motion.h3, p: motion.p, div: motion.div } @@ -62,36 +61,3 @@ export function FadeIn({ children, className, delay = 0 }: { children: ReactNode ) } - -/** Bild, das sich von unten aufdeckt und dabei leicht herauszoomt. */ -export function ImageReveal({ - src, - alt, - className, - imgClassName, -}: { - src: string - alt: string - className?: string - imgClassName?: string -}) { - const transition = { duration: 1.3, ease: EASE_OUT_EXPO } - // Beobachtet wird der ungeclippte Rahmen: ein komplett weggeclipptes Element gilt nie als sichtbar - return ( - - - - - - ) -} diff --git a/src/data/salon.ts b/src/data/salon.ts index ca96f5d..f444ed2 100644 --- a/src/data/salon.ts +++ b/src/data/salon.ts @@ -149,7 +149,7 @@ const images = import.meta.glob(['../assets/team/*.webp', '../assets/pho eager: true, import: 'default', }) -/** Bild-URL aus `src/assets`, z. B. `image('photos/emblem')` */ +/** Bild-URL aus `src/assets`, z. B. `image('photos/hero')` */ export const image = (path: string) => images[`../assets/${path}.webp`] export const team: Member[] = [ diff --git a/src/index.css b/src/index.css index e3dc077..ffb8cb5 100644 --- a/src/index.css +++ b/src/index.css @@ -72,6 +72,45 @@ box-shadow: inset -1px 0 0 var(--grid-line, rgb(10 9 12 / 0.07)); } +/* Logo-Emblem: Linien zeichnen sich, danach füllen sie sich (siehe Emblem.tsx) */ +.emblem .emblem-lines path { + fill: none; + stroke: var(--color-paper); + stroke-width: 2; + stroke-dasharray: 1 1; + stroke-dashoffset: 1; +} + +.emblem .emblem-fill { + opacity: 0; +} + +.emblem[data-drawn] .emblem-lines path { + stroke-dashoffset: 0; + transition: stroke-dashoffset 2.6s var(--ease-out-expo) calc(var(--i) * 30ms); +} + +.emblem[data-drawn] .emblem-lines { + opacity: 0; + transition: opacity 1s ease 2.6s; +} + +.emblem[data-drawn] .emblem-fill { + opacity: 1; + transition: opacity 1.4s ease 1.4s; +} + +@media (prefers-reduced-motion: reduce) { + .emblem .emblem-lines { + display: none; + } + + .emblem .emblem-fill { + opacity: 1; + transition: none; + } +} + /* Rechtstexte (Impressum / Datenschutz) */ @layer components { .legal h2 {