Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a63f79808 | ||
|
|
ef9add6acd | ||
|
|
516f8ccf79 | ||
|
|
9e5ea3dfaf | ||
|
|
2f52ef70ff | ||
|
|
d80e072adc | ||
|
|
734c2775b5 | ||
|
|
9e22ce616d | ||
|
|
97a00e508a | ||
|
|
d8df8f494a | ||
|
|
d4a0a3856e | ||
|
|
4392285570 | ||
|
|
790e18e8a6 |
No files matched your search
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"type": "node_build",
|
||||
"branch": "main",
|
||||
"displayName": "Amin B.Ahmadi Friseur Salon",
|
||||
"templateName": "webklar-preview-template",
|
||||
"projectType": "preview",
|
||||
"public": false,
|
||||
"template": false,
|
||||
"subdomain": "amin-ahmadi-friseur"
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
# Projektdokumentation – Website Amin B. Ahmadi Friseur Salon
|
||||
|
||||
One-Pager für den Barber- und Friseursalon **Amin B. Ahmadi** in Kaiserslautern
|
||||
(Richard-Wagner-Straße 10). Ziel: minimalistisch, modern, wenig Text, interaktive
|
||||
Bild-Elemente – alle Buchungen laufen über die bestehende Setmore-Seite
|
||||
([aminbahmadi.setmore.com](https://aminbahmadi.setmore.com)).
|
||||
|
||||
Wie man die Seite startet, Inhalte ändert und veröffentlicht, steht in der [README](README.md).
|
||||
Dieses Dokument beschreibt, **wie die Seite entstanden ist** und **woher Inhalte und Bilder stammen**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Datengrundlage
|
||||
|
||||
Alle Salon-Daten stammen aus der öffentlichen Setmore-Buchungsseite:
|
||||
|
||||
- 36 Leistungen mit Preisen und Dauer, sortiert in Herren, Damen, Farbe & Form, Kinder, Beauty
|
||||
- Team: Amin B. Ahmadi (Inhaber), Atena, Ahmad, Ali, Younes, Mo Bargus
|
||||
- Adresse, Telefon, E-Mail, Öffnungszeiten (Mo–Sa 9–19 Uhr)
|
||||
- Setmore-ID jeder Leistung → jede Preiszeile öffnet direkt die passende Buchung
|
||||
|
||||
Bereinigt: Tippfehler („Machinenschnitt“, „Balyage“ …), ein doppeltes „Men's Special“ und ein
|
||||
vermutlich falsch einsortiertes „Dauerwelle lang“ unter Kosmetik wurden nicht übernommen.
|
||||
Alles steht zentral in `src/data/salon.ts`.
|
||||
|
||||
## 2. Verlauf der Gestaltung
|
||||
|
||||
| Schritt | Ergebnis |
|
||||
| --- | --- |
|
||||
| 1. Entwurf | Dunkle Seite mit animiertem Seidenhintergrund (WebGL), Chrom-Schriftzug, Laufband, vielen Buttons. |
|
||||
| 2. Feedback | Zu viel Call-to-Action, zu viel Text, Schriftzug gefällt nicht, Hero soll ein **Foto** sein, mehr **interaktive Bild-Elemente**. Vorbild: [crispmtl.com](https://www.crispmtl.com). |
|
||||
| 3. Redesign | Heller, minimalistischer Stil wie CRISP: feine Rasterlinien, breite Versal-Headlines (Archivo Expanded), kleine Serifen-Labels (Instrument Serif), eckige Buttons. „Termin buchen“ nur noch oben rechts als Textlink, im Hero und im Footer. |
|
||||
| 4. Team-Porträts | Einheitliche Studio-Porträts per Bild-KI (siehe 3.1). |
|
||||
| 5. Hero-Bild | Amin bei der Arbeit, per Bild-KI nach dem Vorbild des CRISP-Heros (siehe 3.2). |
|
||||
| 6. Logo | Instagram-Profilbild vektorisiert und in „Über uns“ als animiertes Emblem eingebaut; das „A“ ist das Favicon (siehe 4). |
|
||||
| 7. Galerie | Schwarze Section „Momente“ mit echten Fotos aus dem Salon. |
|
||||
| 8. Körnung | Filmkorn auf allen Fotos, Varianten per Preview ausgewählt (siehe 5). |
|
||||
|
||||
### Aufbau der Seite
|
||||
|
||||
1. **Navigation** – Menü-Button, Name in der Mitte, „Termin buchen“. Farbe passt sich per
|
||||
`mix-blend-difference` automatisch hellen und dunklen Flächen an.
|
||||
2. **Menü** (Vollbild) – große Links; daneben wechselt das Bild zum Link unter der Maus
|
||||
(bei „Team“ ein Raster aus allen Porträts).
|
||||
3. **Hero** – Foto „Amin bei der Arbeit“, Headline „Dein Look. Unser Handwerk.“ unten links,
|
||||
damit Gesichter, Schere und Kamm frei bleiben. Eigener Hochformat-Ausschnitt fürs Handy.
|
||||
4. **Über uns** – „Sechs Profis. Ein Salon.“ mit dem Logo: Die Linien zeichnen sich beim
|
||||
Scrollen, danach folgt ein Lichtreflex wie auf Metall dem Mauszeiger.
|
||||
5. **Team** – Namen als große Liste, das Porträt schiebt sich beim Überfahren/Antippen ins Bild.
|
||||
6. **Momente** – schwarze Galerie; am Desktop bleibt die Section stehen und die Bilder laufen
|
||||
beim Scrollen seitlich durch, am Handy wird gewischt.
|
||||
7. **Preise** – Akkordeon mit kleinen Porträts der Profis, die die Leistungen anbieten;
|
||||
jede Zeile führt direkt zur Setmore-Buchung.
|
||||
8. **Footer** – „Bis zum [Fotoraster] nächsten Schnitt.“, Adresse, Öffnungszeiten mit
|
||||
Live-Status „Jetzt geöffnet“, Kontakt, Impressum, Datenschutz.
|
||||
|
||||
## 3. Bildgenerierung (Nano Banana Pro / Gemini)
|
||||
|
||||
Die Team-Porträts und das Hero-Bild wurden mit **Nano Banana Pro** (Google Gemini) erstellt.
|
||||
Vorgehen: pro Person ein echtes Foto als Referenz hochladen, immer derselbe Prompt – so sehen
|
||||
alle Bilder aus wie aus einem Shooting. Die KI-Bilder bitte vor der Veröffentlichung von den
|
||||
abgebildeten Personen freigeben lassen.
|
||||
|
||||
### 3.1 Team-Porträts
|
||||
|
||||
Referenz: das jeweilige Foto der Person (für Amin ein eigenes Foto, bei Setmore gab es keins).
|
||||
Einstellungen: Seitenverhältnis 4:5, mindestens 2K.
|
||||
|
||||
```
|
||||
Use the uploaded photo only as the identity reference for this person. Create a new, photorealistic studio team portrait of exactly this person for a modern barbershop website.
|
||||
|
||||
Identity: keep the face 100% identical – facial structure, skin tone, eye shape and colour, eyebrows, nose, lips, ears, hairline, hairstyle and hair length, beard shape and density, visible tattoos and earrings. Keep the apparent age. Do not beautify, slim, smooth or alter the person in any way.
|
||||
|
||||
Framing: vertical 4:5, head-and-shoulders portrait from mid-chest up, person centred, eyes at about 38% from the top edge, generous space above the head and on both sides so the image can also be cropped to a square. Shoulders turned about 15 degrees to the left, face and gaze directly into the camera.
|
||||
|
||||
Expression: calm, confident and friendly, relaxed face, subtle closed-mouth smile.
|
||||
|
||||
Wardrobe: plain black crew-neck T-shirt, no logos, no print.
|
||||
|
||||
Background: seamless studio paper backdrop in warm light grey (#d6d4cf), perfectly even, no texture, no objects, very soft slightly darker falloff towards the edges.
|
||||
|
||||
Lighting: large softbox key light from 45 degrees front-left, slightly above eye level; soft fill from the right about 1.5 stops darker; subtle rim light from behind to separate hair and shoulders from the background; soft natural shadows, no hard highlights.
|
||||
|
||||
Camera: full-frame, 85 mm lens, f/5.6, eye level, tack-sharp focus on the eyes.
|
||||
|
||||
Look: high-end editorial portrait photography, neutral white balance, slightly muted natural colours, gentle contrast, natural skin texture with visible pores, no plastic retouching, no HDR, no heavy grain.
|
||||
|
||||
Do not add text, logos, watermarks, props, hands, extra jewellery or accessories that are not in the reference, or other people.
|
||||
```
|
||||
|
||||
Tipp für Konsistenz: das erste gelungene Porträt bei allen weiteren als zweites Bild mitgeben und
|
||||
anhängen: *„Match the background, lighting, framing, crop and colour grading of the second image
|
||||
exactly; take only the person from the first image.“*
|
||||
|
||||
### 3.2 Hero-Bild „Amin bei der Arbeit“
|
||||
|
||||
Referenz: nur Amins Porträt. Die Szene (Pose, Hände, Schere, Kamm, Kunde, Licht) ist komplett
|
||||
beschrieben, angelehnt an das Hero-Foto von crispmtl.com. Der Kunde ist eine erfundene Person.
|
||||
Einstellungen: 16:9, 4K.
|
||||
|
||||
```
|
||||
Photorealistic editorial photograph for a barbershop website hero image. Landscape 16:9. The image only – absolutely no text, letters, headline, logo, button, watermark or graphic overlay.
|
||||
|
||||
BARBER (identity from the attached reference photo):
|
||||
The barber is exactly the man in the attached reference image. Keep his face 100% identical: face shape, skin tone, eyes, eyebrows, nose, lips, ears, beard shape and density, apparent age. Do not beautify or alter him. He wears the same black baseball cap as in the reference, but plain black without any logo or embroidery.
|
||||
|
||||
SCENE AND POSITIONS:
|
||||
A barber at work on a seated client, captured in a calm, precise moment.
|
||||
- The barber stands behind and slightly to the left of the client (as seen from the camera), upper body visible from the cap down to the hips. His head is in the upper left third (about 33% from the left edge, close to the top edge with a little headroom). His torso is turned about 30 degrees towards the client on the right. His head is tilted down about 20 degrees and his eyes look down at the client's head with a focused, calm expression, mouth closed.
|
||||
- His RIGHT hand (lower hand, left of image centre, at chest height about 45% from the left and 50% from the top) holds professional hair-cutting shears: polished stainless steel, slim straight 6-inch blades, blades closed. Thumb in the lower ring, ring finger in the upper ring, index finger resting along the shank. The blade tips point diagonally up and to the right at about 45 degrees towards the side of the client's head, about 20 cm away, not touching.
|
||||
- His LEFT arm is raised, elbow bent outwards to the right, forearm almost horizontal above the client's head. His LEFT hand (about 64% from the left, 25% from the top) points the index finger straight down towards the top of the client's head, checking the line. A long, slim ivory-white cutting comb (about 20 cm) sits clamped between his middle and ring fingers and sticks out horizontally to the right.
|
||||
- The client sits in the foreground, right of centre, in a barber chair that is completely hidden under the cape. He faces the camera straight on, head upright and level, eyes closed, face relaxed and serene, lips closed. His head is centred about 62% from the left; the top of his head is about 35% from the top, just below the barber's pointing finger, and his chin is at about 65% of the height. He is a young man in his mid-twenties with a fresh high skin fade on the sides, a very short dark crop on top, a neat dark moustache and short goatee, and light stubble along the jaw.
|
||||
|
||||
WARDROBE:
|
||||
- Barber: relaxed-fit sage-green long-sleeve button-up shirt with collar, cuffs buttoned, a chest pocket with a small sprig of dried flowers tucked in. A gold curb-chain bracelet on his right wrist, a gold signet ring on his left hand.
|
||||
- Client: matte ivory/cream barber cape with a ribbed elastic collar snug around the neck. The cape covers his shoulders and body and fills the lower right of the frame down to the bottom edge as a large, calm, light area.
|
||||
|
||||
BACKGROUND:
|
||||
Seamless, plain, very light grey-white studio wall (#e6e7e4), no objects, no mirror, no salon interior, no floor visible. Soft, even tone with a slight falloff from lighter upper left to slightly darker grey at the right edge. The right 15% of the frame is empty background.
|
||||
|
||||
LIGHT:
|
||||
Soft, diffused high-key studio light from a large source at front-left, slightly above eye level. Very soft shadows on the right side of both faces, low contrast, no hard highlights, no dramatic lighting.
|
||||
|
||||
CAMERA:
|
||||
Full-frame camera, 45 mm lens, f/4, at the client's eye level, straight-on frontal perspective, no tilt. Both faces, both hands, the shears and the comb in sharp focus.
|
||||
|
||||
COLOUR AND LOOK:
|
||||
Muted, airy and slightly desaturated palette: pale grey-white, sage green, ivory, natural warm skin tones, polished silver steel, small gold accents. Matte, natural finish like a premium editorial campaign, natural skin texture, no HDR, no heavy grain, no plastic retouching.
|
||||
|
||||
AVOID:
|
||||
Any text or logos (including on the cap), extra people, extra or missing fingers, distorted hands, fingers not correctly inside the scissor rings, hair clippings in the air, mirrors, salon furniture, props other than shears and comb.
|
||||
```
|
||||
|
||||
Weiterverarbeitung: Desktop-Version (2000 px), kleinere Version (1000 px), Hochformat-Ausschnitt
|
||||
fürs Handy mit Amin und Schere, Detail-Ausschnitt (Hände, Schere, Kamm) für das Menü.
|
||||
|
||||
## 4. Logo
|
||||
|
||||
- Quelle: Instagram-Profilbild des Salons (`src/assets/logo/instagram-profilbild.jpg`, 583 × 583 px).
|
||||
- Vektorisiert mit **potrace** (Bild 6-fach vergrößert, geschärft, Schwellwert, Kurven-Tracing),
|
||||
Koordinaten bereinigt und komprimiert.
|
||||
- `src/assets/logo/emblem.svg` – nur das Emblem, Farbe `currentColor`, frei skalierbar.
|
||||
- `public/logo-profilbild.svg` – wie das Profilbild: weißes Emblem auf dunklem Quadrat.
|
||||
- **Favicon:** nur das Serifen-„A“ aus dem Emblem (im Tab gut erkennbar); **App-Icon** fürs
|
||||
Handy: das komplette Emblem.
|
||||
- Hinweis: Wegen der kleinen Vorlage ist das Ornament unten minimal kräftiger als im Original.
|
||||
Liegt die Original-Vektordatei vom Designer vor, einfach `emblem.svg` ersetzen.
|
||||
|
||||
## 5. Körnung (Filmkorn)
|
||||
|
||||
Für einen analogen Nacht-Look wurden vier Varianten als Preview erstellt und verglichen:
|
||||
A fein & dezent · B Film ISO 800 · C Nacht ISO 3200 · D Nacht mit Lichthof.
|
||||
|
||||
Ausgewählt:
|
||||
- **Galerie „Momente“:** Variante B (sichtbares, leicht farbiges Korn, angehobenes Schwarz, leicht entsättigt)
|
||||
- **Alle anderen Fotos** (Hero, Team-Porträts, Menü): Variante A (fein)
|
||||
|
||||
Die Körnung ist fest in die Bilddateien eingerechnet. Die unbearbeiteten Originale liegen in
|
||||
`design/original/`; `python3 scripts/grain.py` wendet den Effekt reproduzierbar erneut an.
|
||||
|
||||
## 6. Technik
|
||||
|
||||
- **Vite + React + TypeScript**, **Tailwind CSS**, **Motion** (Animationen), **Lenis** (weiches Scrollen)
|
||||
- Schriften lokal eingebunden (Archivo, Instrument Serif) – keine Google-Fonts-Anfragen
|
||||
- Keine Cookies, kein Tracking, keine externen Einbindungen; Setmore, Google Maps und Facebook
|
||||
nur als normale Links
|
||||
- Bilder als WebP, Galerie- und Team-Bilder werden erst beim Scrollen geladen
|
||||
- Rücksicht auf „Bewegung reduzieren“ in den Systemeinstellungen, Tastaturbedienung
|
||||
(Menü mit Esc, Akkordeon, Team-Liste)
|
||||
- Impressum und Datenschutz als eigene Seiten; SEO-Daten (Schema.org `HairSalon`, Vorschaubild)
|
||||
|
||||
## 7. Offen vor dem Livegang
|
||||
|
||||
- [ ] Platzhalter in `impressum.html` und `datenschutz.html` ausfüllen und rechtlich prüfen lassen
|
||||
- [ ] Domain in `.env` bei `VITE_SITE_URL` eintragen
|
||||
- [ ] Freigabe aller abgebildeten Personen (KI-Porträts, Hero, Kunden in der Galerie)
|
||||
- [ ] Rollen im Team prüfen (`src/data/salon.ts`)
|
||||
@@ -11,6 +11,8 @@ Setmore-Buchung) · Footer mit Fotoraster, Adresse, Öffnungszeiten und Live-Sta
|
||||
**Technik:** Vite, React, TypeScript, Tailwind CSS, Motion, Lenis. Schriften (Archivo, Instrument Serif)
|
||||
liegen lokal, es gibt keine Cookies und keine externen Einbindungen.
|
||||
|
||||
Wie die Seite entstanden ist (Verlauf, Bild-Prompts, Logo, Körnung): siehe [PROJEKT.md](PROJEKT.md).
|
||||
|
||||
## Starten
|
||||
|
||||
```bash
|
||||
@@ -25,20 +27,43 @@ npm run preview # Build lokal ansehen
|
||||
| Was | Wo |
|
||||
| --- | --- |
|
||||
| Preise, Dauer, Leistungen, Öffnungszeiten, Team, Kontakt | `src/data/salon.ts` |
|
||||
| Fotos (Hero, Über uns, Menü, Fotoraster) | `src/assets/photos/*.webp` |
|
||||
| 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` |
|
||||
|
||||
**Fotos:** Hero, „Über uns“ und Menü nutzen die Fotos von der Logo-Wand im Salon (Setmore-Seite). Für den
|
||||
Hero lohnt sich ein eigenes, hochauflösendes Foto (mind. 2400 px breit), z. B. ein Barber bei der Arbeit.
|
||||
Einfach `salon-wand.webp` bzw. `salon-wand-800.webp` ersetzen.
|
||||
**Hero:** Amin bei der Arbeit (per Bild-KI erstellt), abgelegt als `hero.webp` (2000 px), `hero-1000.webp`
|
||||
und `hero-mobile.webp` (Hochformat-Ausschnitt fürs Handy). Die Headline steht unten links, damit Gesichter,
|
||||
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ü.
|
||||
|
||||
**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 `<path>` 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
|
||||
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).
|
||||
|
||||
**Körnung:** Alle Fotos haben einen Filmkorn-Look – die Galerie wie Film ISO 800 (Variante B), alle
|
||||
anderen Fotos fein (Variante A). Die unbearbeiteten Originale liegen in `design/original/`. Neue oder geänderte Fotos
|
||||
dort ablegen (gleicher Pfad wie in `src/assets/`) und `python3 scripts/grain.py` ausführen – das Skript
|
||||
schreibt die gekörnten Versionen nach `src/assets/`.
|
||||
|
||||
**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=`.
|
||||
@@ -48,7 +73,9 @@ 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 des Teams für die Verwendung der Fotos einholen
|
||||
- [ ] 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)
|
||||
|
||||
Der Live-Status „Jetzt geöffnet“ rechnet mit den Öffnungszeiten aus `src/data/salon.ts` und
|
||||
kennt keine Feiertage.
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
<meta name="robots" content="noindex" />
|
||||
<meta name="theme-color" content="#f5f4f1" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<script type="module" src="/src/legal.ts"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 43 KiB |
@@ -7,6 +7,8 @@
|
||||
<meta name="robots" content="noindex" />
|
||||
<meta name="theme-color" content="#f5f4f1" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<script type="module" src="/src/legal.ts"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
/>
|
||||
<meta name="theme-color" content="#f5f4f1" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
|
||||
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 993 B |
@@ -1,4 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" fill="#0a090c"/>
|
||||
<path fill="#f5f4f1" d="M12.31 47 27.06 17H36.94L51.69 47H42.08L39.81 42.19H23.71L21.47 47ZM26.68 35.69H36.84L34.1 29.59Q33.93 29.18 33.63 28.43Q33.32 27.68 32.99 26.83Q32.65 25.98 32.37 25.25Q32.09 24.52 31.96 24.17H31.63Q31.31 24.97 30.89 26.01Q30.47 27.05 30.08 28.02Q29.7 29 29.42 29.61Z"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="421 531 313 313"><rect x="421" y="531" width="313" height="313" rx="50" fill="#0a090c"/><path fill="#fff" d="M574 572c-1 1-1 2-3 5c0 3-1 6-2 7c0 1-2 5-3 8c-2 4-3 7-3 8c0 1-1 2-2 4c-1 2-2 5-3 8c-1 3-3 7-3 8c-4 9-6 15-7 18c-1 2-2 5-3 6c0 0-1 3-2 4c0 2-1 4-2 6c0 1-1 2-1 3c0 0-1 2-1 4c-2 4-8 18-9 20c0 2-1 4-2 7c-1 2-2 5-3 6c-3 8-11 28-11 30c-1 1-1 2-2 3c0 0-1 2-1 4c-1 1-2 5-3 7c-1 3-2 6-3 6c0 1-1 5-3 8c-1 4-3 8-4 10c-1 2-2 6-3 9c-8 19-10 22-18 25c-3 0-4 1-4 2c0 1 2 1 8 1c7 0 13 0 18 0c4 0 5 0 5-2c0 0-1-1-2-1c-4-1-5-3-4-8c1-2 6-16 9-23c0-1 2-4 2-6c1-2 3-7 5-10c1-3 2-6 2-7c1-1 2-3 3-6c0-2 1-4 2-5c0-1 0-3 1-3c0-1 2-4 3-8c0-1 1-3 2-5c1-2 3-7 4-10c1-3 2-6 2-7c1-2 2-2 7-2c6 0 48 0 54 0c7 0 7 0 8 4c3 7 5 12 5 13c2 4 4 10 8 19c1 3 2 5 3 6c0 1 1 3 1 4c0 1 1 3 2 5c1 1 1 3 2 4c0 1 1 3 2 5c1 3 2 5 2 6c0 1 2 5 4 10c6 15 7 18 6 21c-1 1-3 3-4 3c0 0-1 1-1 2c0 1 1 1 8 1c5-1 10-1 20 0c19 0 20 0 21-1c1-1 0-1-4-3c-9-3-13-8-19-25c-1-3-3-6-3-7c-1-2-3-7-5-13c-1-1-2-5-4-9c-5-14-6-16-7-19c-1-1-2-3-2-5c-2-3-3-5-4-10c-2-4-3-7-6-14c-2-4-3-8-4-9c0-2-1-3-1-3c0-1-1-2-1-3c-1-2-4-9-8-19c-1-2-2-5-2-6c-1-1-2-4-3-7c-1-3-3-7-3-8c-1-1-2-3-2-4c0-1-1-3-2-5c0-1-2-4-2-6c-1-2-2-5-3-7c-1-1-3-7-5-12c-7-19-8-21-10-26c-2-7-4-9-7-6zM568 615c1 1 2 2 3 7c2 6 4 10 5 14c3 8 4 9 5 12c1 2 2 4 2 5c1 1 1 3 1 3c1 1 1 2 1 3c1 1 1 2 2 4c1 3 5 12 7 18c4 9 4 9-5 8c-7 0-42 0-46 0c-6 1-6 0-3-7c1-2 3-6 4-9c1-3 2-6 3-7c2-6 5-14 6-16c0-1 1-4 2-7c3-6 7-16 9-22c2-6 2-6 4-6z"/></svg>
|
||||
|
Before Width: | Height: | Size: 413 B After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 583 583"><title>Amin B. Ahmadi – Logo</title><rect width="583" height="583" fill="#02000d"/><g transform="translate(-2.2 107.8) scale(0.5)" fill="#fff"><path d="M359 7c-11 1-20 8-21 16c-1 10 10 24 23 26c8 2 16-5 14-12c-1-5-4-6-10-4c-1 0-2 1-3 1c-2 1-4-1-4-4c0-8 16-9 24-1c10 10 3 30-12 36c-5 2-15 2-26 0c-5-1-6-1-14-1c-4 0-10-1-12-1c-7-1-30-1-38 0c-13 0-27 1-32 2c-2 0-6 1-8 1c-2 0-5 1-7 1c-2 0-4 1-5 1c-2 0-4 0-6 1c-2 0-5 1-7 1c-2 1-4 1-5 2c-2 0-3 0-5 1c-23 5-47 15-64 26c-12 8-20 14-29 24c-9 10-18 24-22 34c-3 11-5 16-6 27c-3 14-2 36 2 53c2 8 1 8-3 4c-8-8-19-9-28-3c-10 8-11 19-1 27c3 2 6 0 5-4c0-2 0-7 1-9c3-7 19 2 23 14c2 5 1 5-3 11c-5 7-7 10-10 13c-4 6-6 11-4 19c1 9 0 11-9 19c-5 4-9 9-12 14c-3 6-4 8-13 17c-17 17-24 25-26 30c0 1-1 2-1 2c-1 1-1 14 0 14c0 1 1 2 2 3c3 4 8 9 15 12c5 3 6 4 8 7c4 6 4 7 7 10c3 5 4 5 8 12c4 6 8 10 12 12c2 0 3 1 4 2c2 1 3 3 4 5c1 5 4 13 7 20c1 1 1 2 1 2c0 1 1 2 2 4c9 13 19 18 32 15c2-1 3-1 3-1c1 0 9-4 11-5c6-4 9-7 11-8c1-1 3-3 4-4c1-1 3-2 4-3c0-1 2-2 2-2c1-1 6-5 11-11c15-13 22-19 32-25c9-4 13-6 14-5c1 0 3 4 3 5c1 2 5 10 7 12c3 5 11 10 18 12c8 2 8 2 5 8c0 2-1 4-2 5c0 1-1 4-2 5c-2 4-3 8-3 12c-2 13-7 20-16 20c-10 0-18-10-12-18c2-3 5-3 5 1c-1 5 4 9 10 7c5-2 7-9 4-15c-4-9-15-12-26-7c-10 5-16 21-11 32c5 12 18 19 31 17c9-1 8-2 9 11c0 10 0 14 1 20c0 2 1 5 1 5c0 1 0 4 0 6c1 2 1 4 2 5c0 3 4 14 7 20c3 9 9 20 12 23c0 1 1 3 2 3c1 2 7 9 10 12c16 14 38 19 53 13c18-9 28-26 24-40c-3-7-10-16-16-19c-1 0-2-1-2-1c-6-4-18-5-25-1c-11 5-14 14-8 23c1 2 8 8 9 9c3 1 5-1 4-5c-2-6-2-10-1-12c7-9 23 3 20 15c-3 9-12 14-24 12c-5 0-5-1-14-5c-11-6-12-7-21-17c-4-5-10-15-12-21c-6-16-8-28-9-45c0-13 1-19 5-28c2-3 8-15 8-17c1-2 2-3 2-2c1 0 1 1 3 6c3 12 7 19 13 28c2 5 4 7 9 13c2 3 12 13 16 17c9 7 13 11 16 13c1 1 4 3 6 5c1 1 4 3 5 4c1 1 3 3 4 4c4 2 6 5 8 6c2 1 3 3 4 4c2 1 5 4 8 6c2 2 6 5 7 6c1 1 2 2 4 3c1 1 3 3 4 4c3 2 10 7 12 8c2 2 5 4 8 6c1 2 2 3 3 3c1 1 4 4 8 7c3 4 8 8 10 10c1 1 4 4 7 6c5 4 22 21 25 25c1 2 3 4 4 5c1 1 6 8 9 13c5 9 10 21 13 33c1 6 1 26-1 33c-7 29-49 37-64 13c-4-8-5-15-1-22c3-7 10-9 15-5c3 4 2 7-4 10c-6 3-7 7-2 12c4 3 5 3 9 3c13 1 20-10 16-23c-5-12-14-17-27-16c-13 2-21 14-21 30c1 11 4 18 11 25c7 6 9 8 17 10c7 1 7 2 6 5c-2 5 1 8 7 6c4-1 4 0 1 4c-4 5-4 11-1 13c4 3 7 2 10-4c2-4 3-4 3 0c0 5 2 11 4 13c1 1 2 2 2 3c5 6 16 8 22 5c3-1 7-5 8-7c2-4 9-7 14-6c2 1 5 3 5 4c0 1-2 2-3 2c-3-1-4-1-5-1c-10 4-9 17 2 18c8 1 14-5 14-13c-1-4-1-6-4-10c-2-4 0-3 6 3c6 7 9 8 13 8c5-1 9-6 7-10c-1 0-1-1-1-2c0-1-1-2-2-4c-4-4-3-4 1-4c10 0 10-11-1-17c-7-5-19-4-27 1c-2 1-3 2-3 2c-2 1-9 11-10 13c0 1 1 2 1 2c1 2 1 2-1 3c-2 0-2-1-3-4c-2-12-15-15-22-5c-2 2-2 2-2 5c-1 6 6 11 13 9c1 0 2 0 2 0c1 1-1 5-5 6c-8 5-20-5-18-15c2-13 11-21 28-23c5 0 23-1 24 0c1 0 1 0 2 0c1 0 9 0 18 0c20 0 20 0 17 4c-5 11 2 25 14 24c6-1 17 4 20 9c0 1 1 2 2 3c1 2 1 7-1 9c-4 8-13 10-18 5c-3-3-2-4 1-3c10 5 18-5 13-13c-7-10-22-7-25 5c-2 9 5 19 15 21c4 1 9-1 15-6c4-4 4-4 3 0c-2 3-3 8-3 11c1 4 2 7 7 12c3 3 5 6 5 6c1 2 3 8 3 9c0 2 2 3 4 3c1-1 2-2 2-5c1-6 2-8 5-11c8-6 11-15 8-22c-1-2-2-5-1-5c0 0 2 1 4 3c8 7 16 7 23 0c6-6 7-12 4-19c-2-5-5-7-9-8c-5-1-8 0-13 4c-4 4-4 10 0 14c3 3 6 3 11 1c4-1 5 0 2 3c-9 9-25-4-19-15c3-7 12-11 22-11c4 0 4 0 6-1c8-3 12-15 8-22c-3-5-3-5 11-5c43-1 52 0 60 2c18 7 24 28 9 37c-5 2-15-1-15-6c0 0 0 0 5 0c8-1 13-6 10-13c-5-11-22-10-23 2c-1 3-2 4-4 4c-1 0-2 0-1-2c1-1 1-2 1-3c-1 0-1-1-2-2c-4-9-13-15-23-16c-12-1-22 5-23 13c0 5 3 7 8 7c4 0 4 0 1 3c-7 10-1 20 9 15c2-1 3-2 7-5c4-5 6-6 7-6c0 0-1 4-2 4c0 1-1 2-1 3c-3 10 1 18 11 18c6 0 11-4 10-10c0-5-4-8-10-8c-5 0-6-2-1-4c5-3 12 0 16 5c5 9 17 11 25 5c7-5 10-10 10-19c0-3 0-5 0-5c1 0 2 2 3 5c3 5 6 7 10 5c4-3 3-8-1-15c-3-5-3-5 2-3c4 1 5 1 7-1c1-3 1-3 0-6c-1-3-1-3 3-4c8-3 13-6 19-12c13-15 11-43-5-51c-4-2-13-3-16-2c0 1-2 1-3 1c-10 2-19 18-15 29c3 9 16 13 24 6c6-5 5-8-2-12c-5-3-6-5-4-8c0-2 4-4 6-4c8-1 14 11 12 21c-8 29-51 30-65 1c-5-10-6-34 0-49c2-8 7-18 10-22c0-1 1-3 3-5c7-11 16-22 29-34c3-3 7-6 9-8c2-1 5-5 8-7c3-2 8-7 11-10c10-9 10-9 15-12c1-1 3-2 5-4c7-5 13-10 18-14c2-3 3-3 8-7c2-1 4-4 6-5c2-2 4-4 5-5c1 0 3-2 5-4c2-1 5-4 7-5c4-4 11-9 17-14c14-12 27-27 32-35c0-1 0-2 1-2c2-3 7-14 10-25c4-10 4-10 7-2c1 3 3 7 4 9c5 10 7 15 7 19c1 6 0 26-1 32c0 1 0 3-1 4c0 1 0 4-1 5c0 2-1 6-2 9c-2 8-7 18-11 24c-7 10-13 17-18 19c-2 1-5 3-8 4c-15 9-32 6-36-6c-4-9 3-18 12-18c7-1 9 4 6 12c-1 5-1 5 0 6c2 2 4 2 10-5c3-4 4-4 5-7c5-16-16-28-34-19c-23 12-26 35-8 52c6 6 14 10 21 11c9 2 24-1 33-7c0 0 2-1 3-1c6-4 20-17 22-23c1-1 2-2 2-3c2-3 8-15 11-22c0-2 1-4 2-6c1-4 4-12 4-16c0-1 1-3 1-3c0 0 0-2 0-4c1-8 2-14 2-23c1-14 0-13 7-12c19 3 38-13 35-29c-3-13-9-19-21-22c-11-2-22 7-19 18c2 9 16 8 15-1c0-5 6-2 7 3c2 9-10 17-20 13c-5-3-8-7-10-20c-1-4-2-7-3-9c0-1-1-4-2-6c-1-2-2-5-3-7c-2-4-2-5 3-6c15-5 23-11 28-26c2-5 3-6 11-2c10 5 17 10 23 15c8 7 8 7 18 16c8 8 13 13 16 15c2 1 4 3 5 4c7 6 16 10 25 12c9 3 21-3 27-13c1-1 1-2 2-3c2-2 7-14 8-19c3-11 3-10 7-12c8-4 10-6 16-16c3-3 6-8 7-9c1-1 2-3 3-4c0-2 1-2 4-7c2-3 3-3 7-5c23-13 25-25 9-42c-1-2-3-5-5-7c-2-2-8-8-13-13c-9-9-9-10-11-13c-1-2-3-5-3-5c-1-1-2-3-2-3c-1-2-6-7-13-14Line truncated
|
||||
|
After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 65 KiB |
@@ -0,0 +1,74 @@
|
||||
"""Filmkorn auf die Website-Fotos rechnen.
|
||||
|
||||
Liest die unbearbeiteten Bilder aus design/original/ und schreibt die gekörnten Versionen
|
||||
nach src/assets/. Galerie: Variante B (Film, ISO 800), alle anderen Fotos (Hero, Team,
|
||||
Menü): Variante A (fein).
|
||||
|
||||
pip install numpy pillow
|
||||
python3 scripts/grain.py
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageFilter
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SRC = ROOT / 'design' / 'original'
|
||||
DST = ROOT / 'src' / 'assets'
|
||||
|
||||
VARIANTS = {
|
||||
# fein & dezent
|
||||
'A': dict(amount=0.016, size=1.2),
|
||||
# Film, ISO 800: sichtbares, leicht farbiges Korn, etwas angehobenes Schwarz, leicht entsättigt
|
||||
'B': dict(amount=0.028, size=1.5, color=0.15, lift=0.03, contrast=1.05, sat=0.92),
|
||||
}
|
||||
|
||||
def variant_for(rel):
|
||||
return 'B' if rel.startswith('gallery/') else 'A'
|
||||
|
||||
|
||||
LUMA = np.array([0.2126, 0.7152, 0.0722], np.float32)
|
||||
|
||||
|
||||
def noise(rng, h, w, size, color):
|
||||
"""Gauß-Rauschen, auf Korngröße weichgezeichnet und wieder auf Standardabweichung 1 normiert."""
|
||||
def one():
|
||||
n = rng.normal(0, 1, (h, w)).astype(np.float32)
|
||||
if size > 1:
|
||||
im = Image.fromarray((n * 40 + 128).clip(0, 255).astype(np.uint8))
|
||||
n = (np.asarray(im.filter(ImageFilter.GaussianBlur(size * 0.5)), np.float32) - 128) / 40
|
||||
n /= n.std() + 1e-6
|
||||
return n
|
||||
|
||||
mono = one()
|
||||
if not color:
|
||||
return np.stack([mono] * 3, -1)
|
||||
return np.stack([mono + color * one() for _ in range(3)], -1)
|
||||
|
||||
|
||||
def grain(img, rng, amount, size, color=0.0, lift=0.0, contrast=1.0, sat=1.0):
|
||||
a = np.asarray(img.convert('RGB'), np.float32) / 255
|
||||
h, w, _ = a.shape
|
||||
a = (a - 0.5) * contrast + 0.5
|
||||
lum = a @ LUMA
|
||||
a = lum[..., None] + (a - lum[..., None]) * sat
|
||||
a = lift + a * (1 - lift)
|
||||
# Korn vor allem in den Mitteltönen, wie bei Film
|
||||
lum = np.clip(a @ LUMA, 0, 1)
|
||||
weight = 4 * lum * (1 - lum) + 0.35
|
||||
a = a + noise(rng, h, w, size, color) * amount * weight[..., None]
|
||||
return Image.fromarray((np.clip(a, 0, 1) * 255).astype(np.uint8))
|
||||
|
||||
|
||||
def main():
|
||||
for i, src in enumerate(sorted(SRC.rglob('*.webp'))):
|
||||
rel = src.relative_to(SRC).as_posix()
|
||||
variant = variant_for(rel)
|
||||
out = grain(Image.open(src), np.random.default_rng(i + 1), **VARIANTS[variant])
|
||||
dst = DST / rel
|
||||
out.save(dst, 'WEBP', quality=82, method=6)
|
||||
print(f'{variant} {rel:28} {src.stat().st_size // 1024:4} KB -> {dst.stat().st_size // 1024:4} KB')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -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 />
|
||||
|
||||
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 156 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1157 913" fill="currentColor"><path d="M359 7c-11 1-20 8-21 16c-1 10 10 24 23 26c8 2 16-5 14-12c-1-5-4-6-10-4c-1 0-2 1-3 1c-2 1-4-1-4-4c0-8 16-9 24-1c10 10 3 30-12 36c-5 2-15 2-26 0c-5-1-6-1-14-1c-4 0-10-1-12-1c-7-1-30-1-38 0c-13 0-27 1-32 2c-2 0-6 1-8 1c-2 0-5 1-7 1c-2 0-4 1-5 1c-2 0-4 0-6 1c-2 0-5 1-7 1c-2 1-4 1-5 2c-2 0-3 0-5 1c-23 5-47 15-64 26c-12 8-20 14-29 24c-9 10-18 24-22 34c-3 11-5 16-6 27c-3 14-2 36 2 53c2 8 1 8-3 4c-8-8-19-9-28-3c-10 8-11 19-1 27c3 2 6 0 5-4c0-2 0-7 1-9c3-7 19 2 23 14c2 5 1 5-3 11c-5 7-7 10-10 13c-4 6-6 11-4 19c1 9 0 11-9 19c-5 4-9 9-12 14c-3 6-4 8-13 17c-17 17-24 25-26 30c0 1-1 2-1 2c-1 1-1 14 0 14c0 1 1 2 2 3c3 4 8 9 15 12c5 3 6 4 8 7c4 6 4 7 7 10c3 5 4 5 8 12c4 6 8 10 12 12c2 0 3 1 4 2c2 1 3 3 4 5c1 5 4 13 7 20c1 1 1 2 1 2c0 1 1 2 2 4c9 13 19 18 32 15c2-1 3-1 3-1c1 0 9-4 11-5c6-4 9-7 11-8c1-1 3-3 4-4c1-1 3-2 4-3c0-1 2-2 2-2c1-1 6-5 11-11c15-13 22-19 32-25c9-4 13-6 14-5c1 0 3 4 3 5c1 2 5 10 7 12c3 5 11 10 18 12c8 2 8 2 5 8c0 2-1 4-2 5c0 1-1 4-2 5c-2 4-3 8-3 12c-2 13-7 20-16 20c-10 0-18-10-12-18c2-3 5-3 5 1c-1 5 4 9 10 7c5-2 7-9 4-15c-4-9-15-12-26-7c-10 5-16 21-11 32c5 12 18 19 31 17c9-1 8-2 9 11c0 10 0 14 1 20c0 2 1 5 1 5c0 1 0 4 0 6c1 2 1 4 2 5c0 3 4 14 7 20c3 9 9 20 12 23c0 1 1 3 2 3c1 2 7 9 10 12c16 14 38 19 53 13c18-9 28-26 24-40c-3-7-10-16-16-19c-1 0-2-1-2-1c-6-4-18-5-25-1c-11 5-14 14-8 23c1 2 8 8 9 9c3 1 5-1 4-5c-2-6-2-10-1-12c7-9 23 3 20 15c-3 9-12 14-24 12c-5 0-5-1-14-5c-11-6-12-7-21-17c-4-5-10-15-12-21c-6-16-8-28-9-45c0-13 1-19 5-28c2-3 8-15 8-17c1-2 2-3 2-2c1 0 1 1 3 6c3 12 7 19 13 28c2 5 4 7 9 13c2 3 12 13 16 17c9 7 13 11 16 13c1 1 4 3 6 5c1 1 4 3 5 4c1 1 3 3 4 4c4 2 6 5 8 6c2 1 3 3 4 4c2 1 5 4 8 6c2 2 6 5 7 6c1 1 2 2 4 3c1 1 3 3 4 4c3 2 10 7 12 8c2 2 5 4 8 6c1 2 2 3 3 3c1 1 4 4 8 7c3 4 8 8 10 10c1 1 4 4 7 6c5 4 22 21 25 25c1 2 3 4 4 5c1 1 6 8 9 13c5 9 10 21 13 33c1 6 1 26-1 33c-7 29-49 37-64 13c-4-8-5-15-1-22c3-7 10-9 15-5c3 4 2 7-4 10c-6 3-7 7-2 12c4 3 5 3 9 3c13 1 20-10 16-23c-5-12-14-17-27-16c-13 2-21 14-21 30c1 11 4 18 11 25c7 6 9 8 17 10c7 1 7 2 6 5c-2 5 1 8 7 6c4-1 4 0 1 4c-4 5-4 11-1 13c4 3 7 2 10-4c2-4 3-4 3 0c0 5 2 11 4 13c1 1 2 2 2 3c5 6 16 8 22 5c3-1 7-5 8-7c2-4 9-7 14-6c2 1 5 3 5 4c0 1-2 2-3 2c-3-1-4-1-5-1c-10 4-9 17 2 18c8 1 14-5 14-13c-1-4-1-6-4-10c-2-4 0-3 6 3c6 7 9 8 13 8c5-1 9-6 7-10c-1 0-1-1-1-2c0-1-1-2-2-4c-4-4-3-4 1-4c10 0 10-11-1-17c-7-5-19-4-27 1c-2 1-3 2-3 2c-2 1-9 11-10 13c0 1 1 2 1 2c1 2 1 2-1 3c-2 0-2-1-3-4c-2-12-15-15-22-5c-2 2-2 2-2 5c-1 6 6 11 13 9c1 0 2 0 2 0c1 1-1 5-5 6c-8 5-20-5-18-15c2-13 11-21 28-23c5 0 23-1 24 0c1 0 1 0 2 0c1 0 9 0 18 0c20 0 20 0 17 4c-5 11 2 25 14 24c6-1 17 4 20 9c0 1 1 2 2 3c1 2 1 7-1 9c-4 8-13 10-18 5c-3-3-2-4 1-3c10 5 18-5 13-13c-7-10-22-7-25 5c-2 9 5 19 15 21c4 1 9-1 15-6c4-4 4-4 3 0c-2 3-3 8-3 11c1 4 2 7 7 12c3 3 5 6 5 6c1 2 3 8 3 9c0 2 2 3 4 3c1-1 2-2 2-5c1-6 2-8 5-11c8-6 11-15 8-22c-1-2-2-5-1-5c0 0 2 1 4 3c8 7 16 7 23 0c6-6 7-12 4-19c-2-5-5-7-9-8c-5-1-8 0-13 4c-4 4-4 10 0 14c3 3 6 3 11 1c4-1 5 0 2 3c-9 9-25-4-19-15c3-7 12-11 22-11c4 0 4 0 6-1c8-3 12-15 8-22c-3-5-3-5 11-5c43-1 52 0 60 2c18 7 24 28 9 37c-5 2-15-1-15-6c0 0 0 0 5 0c8-1 13-6 10-13c-5-11-22-10-23 2c-1 3-2 4-4 4c-1 0-2 0-1-2c1-1 1-2 1-3c-1 0-1-1-2-2c-4-9-13-15-23-16c-12-1-22 5-23 13c0 5 3 7 8 7c4 0 4 0 1 3c-7 10-1 20 9 15c2-1 3-2 7-5c4-5 6-6 7-6c0 0-1 4-2 4c0 1-1 2-1 3c-3 10 1 18 11 18c6 0 11-4 10-10c0-5-4-8-10-8c-5 0-6-2-1-4c5-3 12 0 16 5c5 9 17 11 25 5c7-5 10-10 10-19c0-3 0-5 0-5c1 0 2 2 3 5c3 5 6 7 10 5c4-3 3-8-1-15c-3-5-3-5 2-3c4 1 5 1 7-1c1-3 1-3 0-6c-1-3-1-3 3-4c8-3 13-6 19-12c13-15 11-43-5-51c-4-2-13-3-16-2c0 1-2 1-3 1c-10 2-19 18-15 29c3 9 16 13 24 6c6-5 5-8-2-12c-5-3-6-5-4-8c0-2 4-4 6-4c8-1 14 11 12 21c-8 29-51 30-65 1c-5-10-6-34 0-49c2-8 7-18 10-22c0-1 1-3 3-5c7-11 16-22 29-34c3-3 7-6 9-8c2-1 5-5 8-7c3-2 8-7 11-10c10-9 10-9 15-12c1-1 3-2 5-4c7-5 13-10 18-14c2-3 3-3 8-7c2-1 4-4 6-5c2-2 4-4 5-5c1 0 3-2 5-4c2-1 5-4 7-5c4-4 11-9 17-14c14-12 27-27 32-35c0-1 0-2 1-2c2-3 7-14 10-25c4-10 4-10 7-2c1 3 3 7 4 9c5 10 7 15 7 19c1 6 0 26-1 32c0 1 0 3-1 4c0 1 0 4-1 5c0 2-1 6-2 9c-2 8-7 18-11 24c-7 10-13 17-18 19c-2 1-5 3-8 4c-15 9-32 6-36-6c-4-9 3-18 12-18c7-1 9 4 6 12c-1 5-1 5 0 6c2 2 4 2 10-5c3-4 4-4 5-7c5-16-16-28-34-19c-23 12-26 35-8 52c6 6 14 10 21 11c9 2 24-1 33-7c0 0 2-1 3-1c6-4 20-17 22-23c1-1 2-2 2-3c2-3 8-15 11-22c0-2 1-4 2-6c1-4 4-12 4-16c0-1 1-3 1-3c0 0 0-2 0-4c1-8 2-14 2-23c1-14 0-13 7-12c19 3 38-13 35-29c-3-13-9-19-21-22c-11-2-22 7-19 18c2 9 16 8 15-1c0-5 6-2 7 3c2 9-10 17-20 13c-5-3-8-7-10-20c-1-4-2-7-3-9c0-1-1-4-2-6c-1-2-2-5-3-7c-2-4-2-5 3-6c15-5 23-11 28-26c2-5 3-6 11-2c10 5 17 10 23 15c8 7 8 7 18 16c8 8 13 13 16 15c2 1 4 3 5 4c7 6 16 10 25 12c9 3 21-3 27-13c1-1 1-2 2-3c2-2 7-14 8-19c3-11 3-10 7-12c8-4 10-6 16-16c3-3 6-8 7-9c1-1 2-3 3-4c0-2 1-2 4-7c2-3 3-3 7-5c23-13 25-25 9-42c-1-2-3-5-5-7c-2-2-8-8-13-13c-9-9-9-10-11-13c-1-2-3-5-3-5c-1-1-2-3-2-3c-1-2-6-7-13-14c-6-6-6-4-5-18c0-5 0-6 0-8c-1-4-1-3-10-16c-1-1-2-3-3-3c0 0-1-2-2-3c-3-4-4-6-2-10c3-10 16-19 22-15c2 1 2 6 1 10c-1 8 7 5 11-3Line truncated
|
||||
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 96 KiB |
@@ -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(/<path d="([^"]+)"/g)].map((m) => 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<HTMLDivElement>(null)
|
||||
const gradient = useRef<SVGRadialGradientElement>(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 (
|
||||
<div
|
||||
ref={wrap}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerLeave={onPointerLeave}
|
||||
data-drawn={drawn || reduce ? '' : undefined}
|
||||
className={cn('emblem', className)}
|
||||
>
|
||||
<svg viewBox={viewBox} role="img" aria-label="Logo von Amin B. Ahmadi" className="block h-auto w-full overflow-visible">
|
||||
<defs>
|
||||
<radialGradient ref={gradient} id={id} gradientUnits="userSpaceOnUse" cx={0.3 * VB_W} cy={0.25 * VB_H} r={VB_W * 0.55}>
|
||||
<stop offset="0" stopColor="#ffffff" />
|
||||
<stop offset="0.35" stopColor="#ece9e4" />
|
||||
<stop offset="1" stopColor="#8d8a85" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
{/* Konturen für die Zeichen-Animation */}
|
||||
<g className="emblem-lines" aria-hidden="true">
|
||||
{paths.map((d, i) => (
|
||||
<path key={i} d={d} pathLength={1} style={{ '--i': i } as React.CSSProperties} />
|
||||
))}
|
||||
</g>
|
||||
{/* Gefüllte Linien mit Lichtreflex */}
|
||||
<g className="emblem-fill" fill={`url(#${id})`}>
|
||||
{paths.map((d, i) => (
|
||||
<path key={i} d={d} />
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -13,36 +13,43 @@ export function Hero() {
|
||||
return (
|
||||
<section id="top" ref={ref} className="relative isolate h-svh min-h-[560px] overflow-hidden bg-ink text-paper">
|
||||
<motion.div style={{ y }} className="absolute inset-0 -z-20">
|
||||
{/* Handy: eigener Hochformat-Ausschnitt mit Amin und der Schere */}
|
||||
<picture>
|
||||
<source media="(max-width: 767px)" srcSet={image('photos/hero-mobile')} />
|
||||
<motion.img
|
||||
src={image('photos/salon-wand')}
|
||||
srcSet={`${image('photos/salon-wand-800')} 800w, ${image('photos/salon-wand')} 1317w`}
|
||||
src={image('photos/hero')}
|
||||
srcSet={`${image('photos/hero-1000')} 1000w, ${image('photos/hero')} 2000w`}
|
||||
sizes="100vw"
|
||||
alt={`Barber vor dem Logo an der Wand im Salon ${salon.name}`}
|
||||
alt={`${salon.name} schneidet einem Kunden mit Schere und Kamm die Haare`}
|
||||
fetchPriority="high"
|
||||
initial={{ scale: 1.15 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ duration: 2.2, ease: EASE_OUT_EXPO }}
|
||||
className="size-full object-cover object-[50%_60%] md:object-[50%_100%]"
|
||||
className="size-full object-cover md:object-[50%_25%]"
|
||||
/>
|
||||
</picture>
|
||||
</motion.div>
|
||||
<div aria-hidden="true" className="absolute inset-0 -z-10 bg-ink/35" />
|
||||
<div aria-hidden="true" className="absolute inset-x-0 top-0 -z-10 h-40 bg-linear-to-b from-ink/60 to-transparent" />
|
||||
<div aria-hidden="true" className="absolute inset-x-0 bottom-0 -z-10 h-2/3 bg-linear-to-t from-ink/70 to-transparent" />
|
||||
<div aria-hidden="true" className="absolute inset-0 -z-10 bg-ink/30" />
|
||||
{/* oben dunkler, damit die Navigation auf dem hellen Studiohintergrund lesbar bleibt */}
|
||||
<div aria-hidden="true" className="absolute inset-x-0 top-0 -z-10 h-44 bg-linear-to-b from-ink/80 to-transparent" />
|
||||
<div aria-hidden="true" className="absolute inset-x-0 bottom-0 -z-10 h-3/5 bg-linear-to-t from-ink/75 to-transparent" />
|
||||
<div aria-hidden="true" className="absolute inset-y-0 left-0 -z-10 hidden w-3/5 bg-linear-to-r from-ink/35 to-transparent md:block" />
|
||||
|
||||
<div className="flex h-full flex-col items-center justify-end px-4 pb-10 text-center md:pb-14">
|
||||
{/* Text unten links: Gesichter, Schere und Kamm bleiben frei */}
|
||||
<div className="flex h-full flex-col items-center justify-end px-4 pb-10 text-center md:items-start md:px-8 md:pb-12 md:text-left">
|
||||
<RevealLines
|
||||
as="h1"
|
||||
onLoad
|
||||
delay={0.3}
|
||||
srLabel={`${salon.name} – Barber & Friseur in ${salon.city}. Dein Look, unser Handwerk.`}
|
||||
className="display text-[clamp(1.55rem,6.4vw,5.75rem)]"
|
||||
className="display text-[clamp(1.55rem,4.2vw,4.5rem)]"
|
||||
lines={['Dein Look.', 'Unser Handwerk.']}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, delay: 0.9, ease: EASE_OUT_EXPO }}
|
||||
className="mt-6 md:mt-8"
|
||||
className="mt-6 md:mt-7"
|
||||
>
|
||||
<Button tone="light" />
|
||||
</motion.div>
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { image, salon } from '../data/salon'
|
||||
import { FadeIn, ImageReveal, RevealLines } from './Reveal'
|
||||
import { salon } from '../data/salon'
|
||||
import { Emblem } from './Emblem'
|
||||
import { GridLines } from './GridLines'
|
||||
import { FadeIn, RevealLines } from './Reveal'
|
||||
|
||||
export function Intro() {
|
||||
return (
|
||||
<section id="salon" className="px-4 py-24 md:px-8 md:py-36">
|
||||
<section id="salon" className="relative isolate bg-ink px-4 py-24 text-paper md:px-8 md:py-36">
|
||||
<GridLines tone="dark" />
|
||||
<div className="grid gap-10 md:grid-cols-12 md:gap-8">
|
||||
<ImageReveal
|
||||
src={image('photos/emblem')}
|
||||
alt={`Das Logo von ${salon.name} aus Edelstahl an der Schieferwand im Salon`}
|
||||
className="aspect-[4/5] md:col-span-4"
|
||||
/>
|
||||
{/* Logo wie auf Instagram: helle Linien, freigestellt auf dem dunklen Section-Hintergrund */}
|
||||
<div className="grid aspect-square place-items-center md:col-span-4">
|
||||
<Emblem className="w-[84%]" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-between gap-12 md:col-span-8">
|
||||
<div className="mini flex justify-between">
|
||||
<div className="mini flex justify-between text-paper/60">
|
||||
<span>Über uns</span>
|
||||
<span>{salon.city}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { BOOKING_URL, image, salon } from '../data/salon'
|
||||
import { BOOKING_URL, image, salon, team } from '../data/salon'
|
||||
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', photo: image('photos/emblem-quadrat') },
|
||||
{ href: '#team', label: 'Team', photo: image('photos/mo-wand') },
|
||||
{ href: '#kontakt', label: 'Kontakt', photo: image('photos/emblem-breit-900') },
|
||||
{ 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')] },
|
||||
]
|
||||
|
||||
/** Vollbild-Menü: große Links, daneben wechselt das Bild zum Link unter dem Mauszeiger. */
|
||||
@@ -72,15 +75,18 @@ export function Menu({ open, onClose }: { open: boolean; onClose: () => void })
|
||||
|
||||
<div className="relative hidden md:block">
|
||||
{items.map((item, i) => (
|
||||
<img
|
||||
<div
|
||||
key={item.href}
|
||||
src={item.photo}
|
||||
alt=""
|
||||
className={cn(
|
||||
'absolute inset-0 size-full object-cover transition-all duration-700 ease-out-expo',
|
||||
'absolute inset-0 grid gap-[2px] transition-all duration-700 ease-out-expo',
|
||||
item.photos.length > 1 && 'grid-cols-3',
|
||||
active === i ? 'scale-100 opacity-100' : 'scale-105 opacity-0',
|
||||
)}
|
||||
/>
|
||||
>
|
||||
{item.photos.map((src) => (
|
||||
<img key={src} src={src} alt="" className="size-full min-h-0 object-cover" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<motion.div className={className} initial="hidden" whileInView="show" viewport={{ once: true, amount: 0.25 }}>
|
||||
<motion.div
|
||||
className="size-full overflow-hidden"
|
||||
variants={{ hidden: { clipPath: 'inset(100% 0% 0% 0%)' }, show: { clipPath: 'inset(0% 0% 0% 0%)', transition } }}
|
||||
>
|
||||
<motion.img
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className={cn('size-full object-cover', imgClassName)}
|
||||
variants={{ hidden: { scale: 1.2 }, show: { scale: 1, transition: { ...transition, duration: 1.7 } } }}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -149,7 +149,7 @@ const images = import.meta.glob<string>(['../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[] = [
|
||||
|
||||
@@ -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 {
|
||||
|
||||