Website djnizz.at: Hero- und About-Sektion mit Docker-Deployment
Astro 7 mit Tailwind 4 und Node-Adapter. Alle Seiten werden vorgerendert, der Node-Server ist dabei, damit spaeter einzelne Routen (Booking-Formular) ohne Umbau am Deployment dynamisch werden koennen. Inhalt: - Fullscreen-Hero mit freigestelltem Render, animierten Lasern und Genre-Laufband; Typografie als HTML statt eingebranntem Bildtext - About-Sektion mit Genre-Karten und Eckdaten - Impressum und Datenschutzerklaerung - Zentrale Inhaltsdatei src/data/site.ts Technik: - Selbst gehostete Schriften, keine Cookies, kein Tracking, keine externen Requests - Docker Multi-Stage (238 MB) hinter Caddy mit automatischem HTTPS, Security-Headern und IP-maskierten Logs - scripts/cutout.mjs stellt den Render ueber eine Sobel-Kantenkarte frei und entfernt eingeschlossene Hintergrundflaechen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "djnizz-dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--", "--port", "4321"],
|
||||
"port": 4321
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
node_modules
|
||||
dist
|
||||
.astro
|
||||
.git
|
||||
.gitignore
|
||||
.github
|
||||
.env
|
||||
.env.*
|
||||
.vscode
|
||||
.idea
|
||||
.claude
|
||||
scripts
|
||||
caddy_data
|
||||
caddy_config
|
||||
README.md
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
Caddyfile
|
||||
@@ -0,0 +1,13 @@
|
||||
# Im Repo immer LF speichern und auschecken. Die Dateien landen im Linux-Container,
|
||||
# CRLF wuerde dort z. B. das Caddyfile und die Shell-Aufrufe unnoetig stoeren.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Binaerdateien nicht anfassen
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.webp binary
|
||||
*.avif binary
|
||||
*.ico binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
@@ -0,0 +1,32 @@
|
||||
# build output
|
||||
dist/
|
||||
.output/
|
||||
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
.env.local
|
||||
|
||||
# macOS / Windows
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# editor
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Caddy Zertifikat-Daten (falls jemand lokal testet)
|
||||
caddy_data/
|
||||
caddy_config/
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
# Mailadresse fuer Let's Encrypt (Ablaufwarnungen). Ueberschreibbar per .env
|
||||
email {$ACME_EMAIL:business.djnizz@gmail.com}
|
||||
}
|
||||
|
||||
# --- Weiterleitung www -> Apex ----------------------------------------------
|
||||
# Setzt einen DNS-A-Record fuer www.djnizz.at voraus. Wenn es den nicht gibt,
|
||||
# diesen Block auskommentieren, sonst versucht Caddy dauerhaft erfolglos ein
|
||||
# Zertifikat dafuer zu holen.
|
||||
www.{$DOMAIN:djnizz.at} {
|
||||
redir https://{$DOMAIN:djnizz.at}{uri} permanent
|
||||
}
|
||||
|
||||
# --- Hauptseite -------------------------------------------------------------
|
||||
{$DOMAIN:djnizz.at} {
|
||||
encode zstd gzip
|
||||
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()"
|
||||
Cross-Origin-Opener-Policy "same-origin"
|
||||
-Server
|
||||
}
|
||||
|
||||
# Astro haengt einen Content-Hash an alle Assets unter /_astro/ -
|
||||
# die duerfen daher unbegrenzt gecacht werden.
|
||||
# ">" ersetzt den Header, statt einen zweiten danebenzustellen -
|
||||
# der Node-Adapter setzt fuer gehashte Assets bereits ein Cache-Control.
|
||||
@immutable path /_astro/*
|
||||
header @immutable >Cache-Control "public, max-age=31536000, immutable"
|
||||
|
||||
reverse_proxy web:4321
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/access.log {
|
||||
roll_size 10MiB
|
||||
roll_keep 4
|
||||
roll_keep_for 336h
|
||||
}
|
||||
# Client-IPs werden gekuerzt und Cookies/Auth-Header nicht mitgeloggt.
|
||||
# Muss zur Datenschutzerklaerung passen.
|
||||
format filter {
|
||||
wrap json
|
||||
fields {
|
||||
request>remote_ip ip_mask {
|
||||
ipv4 24
|
||||
ipv6 32
|
||||
}
|
||||
request>headers>Cookie delete
|
||||
request>headers>Authorization delete
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---------------------------------------------------------------- Build ----
|
||||
FROM node:24-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
# Erst nur die Manifeste kopieren: solange sich die Dependencies nicht
|
||||
# aendern, bleibt der npm-ci-Layer im Cache.
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
ENV ASTRO_TELEMETRY_DISABLED=1
|
||||
RUN npm run build
|
||||
|
||||
# -------------------------------------------------------------- Runtime ----
|
||||
FROM node:24-alpine AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=4321 \
|
||||
ASTRO_TELEMETRY_DISABLED=1
|
||||
|
||||
# Nur Laufzeit-Abhaengigkeiten. Tailwind, sharp & Co. stehen in
|
||||
# devDependencies und landen damit nicht im finalen Image.
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev --omit=optional && npm cache clean --force
|
||||
|
||||
COPY --from=build /app/dist ./dist
|
||||
|
||||
USER node
|
||||
EXPOSE 4321
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:4321/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
|
||||
CMD ["node", "./dist/server/entry.mjs"]
|
||||
@@ -0,0 +1,163 @@
|
||||
# djnizz.at
|
||||
|
||||
Website von DJ NIZZ. Astro 7 (statisch vorgerendert) hinter einem Node-Server,
|
||||
Tailwind 4, ausgeliefert per Docker + Caddy mit automatischem HTTPS.
|
||||
|
||||
## Inhalte ändern
|
||||
|
||||
Fast alles steht in **[`src/data/site.ts`](src/data/site.ts)** – Texte, Genres,
|
||||
Kontaktdaten, Navigation, Social-Links. Für normale Textänderungen musst du
|
||||
keine Komponente anfassen.
|
||||
|
||||
- **Social-Profile**: In `SOCIALS` die `url` eintragen. Einträge ohne URL werden
|
||||
automatisch ausgeblendet, du kannst also alles stehen lassen, was du noch
|
||||
nicht hast.
|
||||
- **Neues Genre**: In `GENRES` ergänzen. Marquee, Genre-Karten, Zähler in der
|
||||
About-Sektion, Footer-Text und die strukturierten Daten ziehen automatisch nach.
|
||||
Für die Akzentfarbe muss der Name in `accentVar` in
|
||||
[`src/components/About.astro`](src/components/About.astro) existieren.
|
||||
- **Impressum / Datenschutz**: [`src/pages/impressum.astro`](src/pages/impressum.astro)
|
||||
und [`src/pages/datenschutz.astro`](src/pages/datenschutz.astro).
|
||||
|
||||
## Lokal entwickeln
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Läuft auf http://localhost:4321.
|
||||
|
||||
```bash
|
||||
npm run build # nach dist/ bauen
|
||||
npm run preview # gebaute Seite wie in Produktion starten
|
||||
```
|
||||
|
||||
## Bild-Assets
|
||||
|
||||
Die Quellbilder liegen in `src/assets/`. Abgeleitete Dateien (Hero-Hintergrund,
|
||||
OG-Bild, Favicons) erzeugt:
|
||||
|
||||
```bash
|
||||
npm run assets
|
||||
```
|
||||
|
||||
Der freigestellte Render `src/assets/nizz-cutout.png` entsteht separat aus dem
|
||||
Ganzkörper-Bild:
|
||||
|
||||
```bash
|
||||
node scripts/cutout.mjs pfad/zum/render.jpg src/assets/nizz-cutout.png 70
|
||||
```
|
||||
|
||||
Das Skript trennt über eine Sobel-Kantenkarte frei und entfernt zusätzlich
|
||||
eingeschlossene Hintergrundflächen (z. B. zwischen Arm und Oberkörper). Der
|
||||
letzte Parameter ist die Kantenschwelle – höher = aggressiver, damit fallen
|
||||
weiche Bodenschatten weg.
|
||||
|
||||
## Deployment auf Oracle Cloud
|
||||
|
||||
Läuft sowohl auf Ampere A1 (ARM) als auch auf x86 – die verwendeten Images sind
|
||||
Multi-Arch.
|
||||
|
||||
### 1. Netzwerk freigeben (zwei Stellen!)
|
||||
|
||||
Der häufigste Stolperstein bei OCI: Ports müssen **sowohl** in der Cloud-Firewall
|
||||
**als auch** in der Instanz freigegeben werden.
|
||||
|
||||
**a) VCN Security List / NSG** in der OCI-Konsole – Ingress-Regeln für
|
||||
`0.0.0.0/0` auf TCP 80 und TCP 443 (und UDP 443, wenn HTTP/3 genutzt werden soll).
|
||||
|
||||
**b) Firewall in der Instanz.** Oracle-Linux-Images bringen firewalld mit:
|
||||
|
||||
```bash
|
||||
sudo firewall-cmd --permanent --add-service=http --add-service=https
|
||||
sudo firewall-cmd --permanent --add-port=443/udp
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
Ubuntu-Images von Oracle haben stattdessen eine restriktive iptables-Regel:
|
||||
|
||||
```bash
|
||||
sudo iptables -I INPUT 6 -m state --state NEW -p tcp --dport 80 -j ACCEPT
|
||||
sudo iptables -I INPUT 6 -m state --state NEW -p tcp --dport 443 -j ACCEPT
|
||||
sudo netfilter-persistent save
|
||||
```
|
||||
|
||||
### 2. DNS
|
||||
|
||||
A-Record `djnizz.at` → öffentliche IP der Instanz. Wenn `www.djnizz.at` genutzt
|
||||
werden soll, dafür ebenfalls einen A-Record anlegen – sonst den `www`-Block im
|
||||
[`Caddyfile`](Caddyfile) auskommentieren, weil Caddy sonst dauerhaft erfolglos
|
||||
ein Zertifikat dafür anfordert.
|
||||
|
||||
### 3. Docker installieren
|
||||
|
||||
```bash
|
||||
curl -fsSL https://get.docker.com | sudo sh
|
||||
sudo usermod -aG docker $USER
|
||||
```
|
||||
|
||||
Danach einmal neu einloggen.
|
||||
|
||||
### 4. Starten
|
||||
|
||||
```bash
|
||||
git clone https://github.com/nizzgruber/djnizz_website.git
|
||||
cd djnizz_website
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Caddy holt sich das Let's-Encrypt-Zertifikat automatisch, sobald die Domain auf
|
||||
den Server zeigt. Fortschritt prüfen:
|
||||
|
||||
```bash
|
||||
docker compose logs -f caddy
|
||||
```
|
||||
|
||||
### Updates ausrollen
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### Konfiguration
|
||||
|
||||
Domain und ACME-Mailadresse lassen sich per `.env` neben der
|
||||
`docker-compose.yml` überschreiben:
|
||||
|
||||
```env
|
||||
DOMAIN=djnizz.at
|
||||
ACME_EMAIL=business.djnizz@gmail.com
|
||||
```
|
||||
|
||||
> Das Volume `caddy_data` enthält die TLS-Zertifikate. Nicht löschen – sonst
|
||||
> werden bei jedem Neustart neue Zertifikate angefordert und irgendwann greift
|
||||
> das Rate-Limit von Let's Encrypt.
|
||||
|
||||
## Datenschutz
|
||||
|
||||
Die Seite lädt nichts von externen Servern: Schriften sind selbst gehostet, es
|
||||
gibt keine Cookies, kein Tracking und keine eingebetteten Player. Caddy kürzt in
|
||||
den Access-Logs die Client-IP (`ip_mask`) und löscht sie nach 14 Tagen – das muss
|
||||
so bleiben, sonst stimmt die Datenschutzerklärung nicht mehr.
|
||||
|
||||
Wenn später ein Booking-Formular, Analytics oder Spotify-/YouTube-Embeds
|
||||
dazukommen, muss `src/pages/datenschutz.astro` entsprechend erweitert werden.
|
||||
|
||||
## Struktur
|
||||
|
||||
```
|
||||
src/
|
||||
assets/ Bilder (werden beim Build zu WebP/AVIF optimiert)
|
||||
components/ Header, Hero, About, Footer, SocialIcon
|
||||
data/site.ts Zentrale Inhalts- und Konfigurationsdatei
|
||||
layouts/ Base (SEO, JSON-LD), Legal
|
||||
pages/ index, impressum, datenschutz, 404
|
||||
styles/ global.css (Tailwind-Theme, Animationen)
|
||||
scripts/ Einmalige Bildaufbereitung
|
||||
```
|
||||
|
||||
Die Seiten sind alle vorgerendert. Der Node-Adapter ist trotzdem drin, damit
|
||||
später einzelne Routen (z. B. `/api/booking`) mit `export const prerender = false`
|
||||
dynamisch werden können, ohne dass sich am Deployment etwas ändert.
|
||||
@@ -0,0 +1,27 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import node from '@astrojs/node';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
// Muss mit SITE.url in src/data/site.ts uebereinstimmen.
|
||||
site: 'https://djnizz.at',
|
||||
|
||||
// Alle Seiten werden beim Build vorgerendert (maximale Performance).
|
||||
// Der Node-Adapter serviert sie und erlaubt es, spaeter einzelne Routen
|
||||
// (z. B. /api/booking) mit `export const prerender = false` dynamisch zu machen.
|
||||
adapter: node({ mode: 'standalone' }),
|
||||
|
||||
integrations: [sitemap()],
|
||||
|
||||
image: {
|
||||
// Bilder werden beim Build zu AVIF/WebP konvertiert.
|
||||
responsiveStyles: true,
|
||||
},
|
||||
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
image: djnizz-website:latest
|
||||
container_name: djnizz-web
|
||||
restart: unless-stopped
|
||||
# Kein ports-Mapping: der Node-Prozess ist nur im internen Netz
|
||||
# erreichbar, nach aussen geht ausschliesslich Caddy.
|
||||
expose:
|
||||
- '4321'
|
||||
networks:
|
||||
- djnizz
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: djnizz-caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '80:80'
|
||||
- '443:443'
|
||||
- '443:443/udp' # HTTP/3
|
||||
environment:
|
||||
DOMAIN: ${DOMAIN:-djnizz.at}
|
||||
ACME_EMAIL: ${ACME_EMAIL:-business.djnizz@gmail.com}
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data # Zertifikate - dieses Volume nicht loeschen
|
||||
- caddy_config:/config
|
||||
- caddy_logs:/var/log/caddy
|
||||
depends_on:
|
||||
- web
|
||||
networks:
|
||||
- djnizz
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
caddy_logs:
|
||||
|
||||
networks:
|
||||
djnizz:
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "djnizz-website",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Offizielle Website von DJ NIZZ - djnizz.at",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "node ./dist/server/entry.mjs",
|
||||
"assets": "node scripts/assets.mjs",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^11.1.2",
|
||||
"astro": "^7.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.10",
|
||||
"@astrojs/sitemap": "^3.7.3",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource/archivo-black": "^5.3.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"sharp": "^0.35.3",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 79 KiB |
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://djnizz.at/sitemap-index.xml
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "DJ NIZZ",
|
||||
"short_name": "DJ NIZZ",
|
||||
"description": "DJ NIZZ - Hardstyle, Techno, House & Commerce",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#05070f",
|
||||
"theme_color": "#05070f",
|
||||
"icons": [
|
||||
{ "src": "/favicon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/favicon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Leitet die abgeleiteten Bild-Assets aus den Quelldateien ab.
|
||||
* Einmalig ausfuehren, wenn sich Banner oder Logo aendern:
|
||||
* node scripts/assets.mjs
|
||||
*/
|
||||
import sharp from 'sharp';
|
||||
import { statSync } from 'node:fs';
|
||||
|
||||
const BANNER = 'src/assets/hero-banner.jpg';
|
||||
const LOGO = 'src/assets/logo-white.png';
|
||||
const BRAND_BG = { r: 11, g: 16, b: 32, alpha: 1 };
|
||||
|
||||
// 1) Club-/Stage-Ausschnitt des Banners: alles links des eingebrannten
|
||||
// "DJ NIZZ"-Schriftzugs (dessen linke Kante liegt bei x = 909) und ohne
|
||||
// die Figur. Dient als Hintergrundebene im Hero, Typo und Figur kommen
|
||||
// als HTML darueber. Breiter schneiden lassen die Geisterbuchstaben
|
||||
// des Schriftzugs im Hintergrund auftauchen.
|
||||
await sharp(BANNER)
|
||||
.extract({ left: 0, top: 0, width: 880, height: 1536 })
|
||||
.jpeg({ quality: 86, mozjpeg: true })
|
||||
.toFile('src/assets/hero-club.jpg');
|
||||
|
||||
// 2) Open-Graph-Bild 1200x630 aus dem kompletten Banner
|
||||
await sharp(BANNER)
|
||||
.extract({ left: 0, top: 45, width: 2752, height: 1445 })
|
||||
.resize(1200, 630)
|
||||
.jpeg({ quality: 88, mozjpeg: true })
|
||||
.toFile('public/og-image.jpg');
|
||||
|
||||
// 3) Favicons: das Logo ist weiss auf transparent und waere im hellen
|
||||
// Browser-Tab unsichtbar -> auf Markenblau setzen.
|
||||
const trimmed = await sharp(LOGO)
|
||||
.trim()
|
||||
.resize(880, 880, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
// sharp wendet resize() intern vor composite() an, unabhaengig von der
|
||||
// Aufrufreihenfolge. Daher erst die 1024er Kachel bauen, dann separat skalieren.
|
||||
const icon = await sharp({ create: { width: 1024, height: 1024, channels: 4, background: BRAND_BG } })
|
||||
.composite([{ input: trimmed, gravity: 'center' }])
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
for (const size of [32, 180, 192, 512]) {
|
||||
const out = size === 180 ? 'public/apple-touch-icon.png' : `public/favicon-${size}.png`;
|
||||
await sharp(icon).resize(size, size).png({ compressionLevel: 9 }).toFile(out);
|
||||
}
|
||||
|
||||
const files = [
|
||||
'src/assets/hero-banner.jpg',
|
||||
'src/assets/hero-club.jpg',
|
||||
'src/assets/nizz-cutout.png',
|
||||
'src/assets/logo-white.png',
|
||||
'public/og-image.jpg',
|
||||
'public/favicon-32.png',
|
||||
'public/favicon-192.png',
|
||||
'public/favicon-512.png',
|
||||
'public/apple-touch-icon.png',
|
||||
];
|
||||
for (const f of files) {
|
||||
const m = await sharp(f).metadata();
|
||||
console.log(f.padEnd(32), `${m.width}x${m.height}`.padEnd(12), `${(statSync(f).size / 1024).toFixed(0)} KB`);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Stellt den DJ-NIZZ-Render frei.
|
||||
*
|
||||
* Der Render ist eine Vektor-Illustration: das Motiv ist durchgehend von dunkler
|
||||
* Linework umschlossen, der Hintergrund ist ein sehr weicher Verlauf. Ein reiner
|
||||
* Farb-Flood-Fill laeuft durch die hellen Hemd- und Hosenpartien durch (die sind
|
||||
* farblich fast identisch mit dem Hintergrund). Deshalb wird der Fill hier an einer
|
||||
* Sobel-Kantenkarte gestoppt: starke Kanten = Wand, alles was vom Bildrand aus ohne
|
||||
* Wandkontakt erreichbar ist = Hintergrund.
|
||||
*
|
||||
* Aufruf: node scripts/cutout.mjs <input> <output.png> [edgeThreshold]
|
||||
*/
|
||||
import sharp from 'sharp';
|
||||
|
||||
const SRC = process.argv[2];
|
||||
const OUT = process.argv[3];
|
||||
const EDGE_T = Number(process.argv[4] ?? 20);
|
||||
|
||||
const { data, info } = await sharp(SRC)
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const { width: W, height: H, channels: C } = info;
|
||||
console.log(`source ${W}x${H} channels=${C} edgeThreshold=${EDGE_T}`);
|
||||
|
||||
// --- Luminanz -------------------------------------------------------------
|
||||
const grey = new Float32Array(W * H);
|
||||
for (let i = 0; i < W * H; i++) {
|
||||
const p = i * C;
|
||||
grey[i] = 0.299 * data[p] + 0.587 * data[p + 1] + 0.114 * data[p + 2];
|
||||
}
|
||||
|
||||
// --- Sobel-Kantenkarte ----------------------------------------------------
|
||||
const wall = new Uint8Array(W * H);
|
||||
let wallCount = 0;
|
||||
for (let y = 1; y < H - 1; y++) {
|
||||
for (let x = 1; x < W - 1; x++) {
|
||||
const i = y * W + x;
|
||||
const tl = grey[i - W - 1], t = grey[i - W], tr = grey[i - W + 1];
|
||||
const l = grey[i - 1], r = grey[i + 1];
|
||||
const bl = grey[i + W - 1], b = grey[i + W], br = grey[i + W + 1];
|
||||
const gx = tl + 2 * l + bl - tr - 2 * r - br;
|
||||
const gy = tl + 2 * t + tr - bl - 2 * b - br;
|
||||
if (Math.abs(gx) + Math.abs(gy) > EDGE_T) {
|
||||
wall[i] = 1;
|
||||
wallCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`edge pixels: ${wallCount} (${((wallCount / (W * H)) * 100).toFixed(1)}%)`);
|
||||
|
||||
// --- Flood-Fill vom Bildrand, an Kanten blockiert --------------------------
|
||||
const bg = new Uint8Array(W * H);
|
||||
const queue = new Int32Array(W * H);
|
||||
let qh = 0;
|
||||
let qt = 0;
|
||||
const push = (i) => {
|
||||
if (!bg[i] && !wall[i]) {
|
||||
bg[i] = 1;
|
||||
queue[qt++] = i;
|
||||
}
|
||||
};
|
||||
|
||||
for (let x = 0; x < W; x++) {
|
||||
push(x);
|
||||
push((H - 1) * W + x);
|
||||
}
|
||||
for (let y = 0; y < H; y++) {
|
||||
push(y * W);
|
||||
push(y * W + W - 1);
|
||||
}
|
||||
|
||||
while (qh < qt) {
|
||||
const i = queue[qh++];
|
||||
const x = i % W;
|
||||
const y = (i / W) | 0;
|
||||
if (x > 0) push(i - 1);
|
||||
if (x < W - 1) push(i + 1);
|
||||
if (y > 0) push(i - W);
|
||||
if (y < H - 1) push(i + W);
|
||||
}
|
||||
|
||||
let bgCount = 0;
|
||||
for (let i = 0; i < bg.length; i++) bgCount += bg[i];
|
||||
console.log(`vom Rand erreicht: ${((bgCount / bg.length) * 100).toFixed(1)}%`);
|
||||
|
||||
// --- Eingeschlossene Hintergrundflaechen ------------------------------------
|
||||
// Flaechen wie zwischen Arm und Oberkoerper (Hand in der Hosentasche) sind
|
||||
// ringsum von Linework umschlossen und damit vom Bildrand aus nicht
|
||||
// erreichbar. Sie werden ueber ihre Farbe erkannt: liegt der Mittelwert einer
|
||||
// eingeschlossenen Flaeche im Farbbereich des Hintergrunds, ist es Hintergrund.
|
||||
// Der Hintergrund ist ein Verlauf - ein globaler Mittelwert waere so unscharf,
|
||||
// dass auch die cremefarbenen Shorts hineinfielen. Deshalb wird pro Bildzeile
|
||||
// gemittelt und eine Tasche gegen den Hintergrund *auf ihrer eigenen Hoehe*
|
||||
// geprueft.
|
||||
const rowSum = [new Float64Array(H), new Float64Array(H), new Float64Array(H)];
|
||||
const rowCount = new Float64Array(H);
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
const i = y * W + x;
|
||||
if (!bg[i]) continue;
|
||||
rowCount[y]++;
|
||||
for (let c = 0; c < 3; c++) rowSum[c][y] += data[i * C + c];
|
||||
}
|
||||
}
|
||||
|
||||
const BAND = 40; // +/- Zeilen, ueber die der lokale Hintergrund gemittelt wird
|
||||
const HOLE_TOL = Number(process.env.CUTOUT_HOLE_TOL ?? 22); // 0 = Taschensuche aus
|
||||
const MIN_HOLE = 400; // kleiner = Bildrauschen bzw. Details im Motiv
|
||||
|
||||
// Obergrenze relativ zur Motivflaeche. Noetig, weil die cremefarbenen Shorts
|
||||
// dem Hintergrund auf ihrer Hoehe farblich fast exakt entsprechen
|
||||
// (Abweichung nur 10/1/3) und ueber die Farbe allein nicht zu trennen sind.
|
||||
// Groessenordnung im Ausgangsbild: Armluecken ~3.000-7.000 px,
|
||||
// Shorts ~38.000-48.000 px bei ~713.000 px Motivflaeche.
|
||||
const subjectPixels = W * H - bgCount;
|
||||
const MAX_HOLE = Math.round(subjectPixels * Number(process.env.CUTOUT_MAX_HOLE ?? 0.03));
|
||||
console.log(`Taschengroesse zulaessig: ${MIN_HOLE} .. ${MAX_HOLE} px`);
|
||||
|
||||
/** Mittlere Hintergrundfarbe rund um Zeile `y`, oder null bei zu wenig Daten. */
|
||||
const localBackground = (y) => {
|
||||
const from = Math.max(0, y - BAND);
|
||||
const to = Math.min(H - 1, y + BAND);
|
||||
let n = 0;
|
||||
const sum = [0, 0, 0];
|
||||
for (let r = from; r <= to; r++) {
|
||||
n += rowCount[r];
|
||||
for (let c = 0; c < 3; c++) sum[c] += rowSum[c][r];
|
||||
}
|
||||
return n < 500 ? null : sum.map((s) => s / n);
|
||||
};
|
||||
const seen = new Uint8Array(W * H);
|
||||
const pocket = new Int32Array(W * H);
|
||||
let holePixels = 0;
|
||||
let holeCount = 0;
|
||||
|
||||
for (let seed = 0; seed < W * H; seed++) {
|
||||
if (bg[seed] || wall[seed] || seen[seed]) continue;
|
||||
|
||||
let ph = 0;
|
||||
let pt = 0;
|
||||
seen[seed] = 1;
|
||||
pocket[pt++] = seed;
|
||||
const sum = [0, 0, 0];
|
||||
let rowTotal = 0;
|
||||
let colTotal = 0;
|
||||
|
||||
while (ph < pt) {
|
||||
const i = pocket[ph++];
|
||||
for (let c = 0; c < 3; c++) sum[c] += data[i * C + c];
|
||||
rowTotal += (i / W) | 0;
|
||||
colTotal += i % W;
|
||||
const x = i % W;
|
||||
const y = (i / W) | 0;
|
||||
const visit = (n) => {
|
||||
if (!bg[n] && !wall[n] && !seen[n]) {
|
||||
seen[n] = 1;
|
||||
pocket[pt++] = n;
|
||||
}
|
||||
};
|
||||
if (x > 0) visit(i - 1);
|
||||
if (x < W - 1) visit(i + 1);
|
||||
if (y > 0) visit(i - W);
|
||||
if (y < H - 1) visit(i + W);
|
||||
}
|
||||
|
||||
if (pt < MIN_HOLE) continue;
|
||||
|
||||
const reference = localBackground(Math.round(rowTotal / pt));
|
||||
if (!reference) continue;
|
||||
const mean = sum.map((s) => s / pt);
|
||||
const looksLikeBackground =
|
||||
pt <= MAX_HOLE && mean.every((m, c) => Math.abs(m - reference[c]) <= HOLE_TOL);
|
||||
|
||||
if (process.env.CUTOUT_DEBUG) {
|
||||
const fmt = (v) => v.map((n) => n.toFixed(0).padStart(3)).join(',');
|
||||
console.log(
|
||||
` Tasche x=${String(Math.round(colTotal / pt)).padStart(4)} y=${String(Math.round(rowTotal / pt)).padStart(4)} ${String(pt).padStart(6)} px` +
|
||||
` mean=(${fmt(mean)}) bg=(${fmt(reference)})` +
|
||||
` d=(${fmt(mean.map((m, c) => Math.abs(m - reference[c])))})` +
|
||||
` -> ${looksLikeBackground ? 'HINTERGRUND' : 'Motiv'}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!looksLikeBackground) continue;
|
||||
|
||||
for (let k = 0; k < pt; k++) bg[pocket[k]] = 1;
|
||||
holePixels += pt;
|
||||
holeCount++;
|
||||
}
|
||||
|
||||
console.log(`eingeschlossene Hintergrundflaechen: ${holeCount} (${holePixels} px)`);
|
||||
|
||||
bgCount = 0;
|
||||
for (let i = 0; i < bg.length; i++) bgCount += bg[i];
|
||||
console.log(`background: ${((bgCount / bg.length) * 100).toFixed(1)}% subject: ${(100 - (bgCount / bg.length) * 100).toFixed(1)}%`);
|
||||
|
||||
// --- Morphologisches Opening ----------------------------------------------
|
||||
// Erodieren trennt duenne Anhaengsel (Horizontlinie am Schuh, Schattenreste) vom
|
||||
// Koerper ab, damit sie der Groesste-Komponente-Filter danach verwirft.
|
||||
const solid = Buffer.alloc(W * H);
|
||||
for (let i = 0; i < W * H; i++) solid[i] = bg[i] ? 0 : 255;
|
||||
|
||||
const morph = async (buf, blurSigma, threshold) => {
|
||||
const out = await sharp(buf, { raw: { width: W, height: H, channels: 1 } })
|
||||
.blur(blurSigma)
|
||||
// Harte Schwelle bei `threshold` (0..1). linear() rechnet auf 0..255,
|
||||
// die Verschiebung muss also mit 255*255 skaliert werden.
|
||||
.linear(255, -threshold * 255 * 255)
|
||||
.toColourspace('b-w')
|
||||
.raw()
|
||||
.toBuffer();
|
||||
if (out.length !== W * H) throw new Error(`Maske hat ${out.length} Bytes, erwartet ${W * H}`);
|
||||
return out;
|
||||
};
|
||||
|
||||
const opened = await morph(solid, 2.0, 0.86); // erodiert ~2 px
|
||||
|
||||
// --- Nur die groesste zusammenhaengende Motivflaeche behalten ---------------
|
||||
// Entfernt Kantenrauschen im Hintergrund (z. B. Reste des Bodenschattens).
|
||||
for (let i = 0; i < W * H; i++) bg[i] = opened[i] > 127 ? 0 : 1;
|
||||
|
||||
const label = new Int32Array(W * H).fill(-1);
|
||||
let best = -1;
|
||||
let bestSize = 0;
|
||||
let current = 0;
|
||||
for (let seed = 0; seed < W * H; seed++) {
|
||||
if (bg[seed] || label[seed] !== -1) continue;
|
||||
let size = 0;
|
||||
qh = 0;
|
||||
qt = 0;
|
||||
label[seed] = current;
|
||||
queue[qt++] = seed;
|
||||
while (qh < qt) {
|
||||
const i = queue[qh++];
|
||||
size++;
|
||||
const x = i % W;
|
||||
const y = (i / W) | 0;
|
||||
const visit = (n) => {
|
||||
if (!bg[n] && label[n] === -1) {
|
||||
label[n] = current;
|
||||
queue[qt++] = n;
|
||||
}
|
||||
};
|
||||
if (x > 0) visit(i - 1);
|
||||
if (x < W - 1) visit(i + 1);
|
||||
if (y > 0) visit(i - W);
|
||||
if (y < H - 1) visit(i + W);
|
||||
}
|
||||
if (size > bestSize) {
|
||||
bestSize = size;
|
||||
best = current;
|
||||
}
|
||||
current++;
|
||||
}
|
||||
console.log(`components: ${current}, largest: ${bestSize} px`);
|
||||
|
||||
const largest = Buffer.alloc(W * H);
|
||||
for (let i = 0; i < W * H; i++) largest[i] = label[i] === best ? 255 : 0;
|
||||
|
||||
// Opening abschliessen: wieder dilatieren und mit der Originalmaske schneiden,
|
||||
// damit die Silhouette exakt bleibt, die abgetrennten Anhaengsel aber weg sind.
|
||||
const dilated = await morph(largest, 2.0, 0.14);
|
||||
const mask = Buffer.alloc(W * H);
|
||||
for (let i = 0; i < W * H; i++) mask[i] = dilated[i] > 127 && solid[i] > 127 ? 255 : 0;
|
||||
|
||||
// --- Kante glaetten und minimal erodieren ----------------------------------
|
||||
// Weicher Verlauf statt harter Schwelle -> antialiaste Silhouette ohne hellen Saum.
|
||||
const alpha = await sharp(mask, { raw: { width: W, height: H, channels: 1 } })
|
||||
.blur(1.1)
|
||||
.linear(3, -1.7 * 255)
|
||||
.toColourspace('b-w')
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
if (alpha.length !== W * H) {
|
||||
throw new Error(`Alpha hat ${alpha.length} Bytes, erwartet ${W * H}`);
|
||||
}
|
||||
|
||||
// --- Bounding Box ----------------------------------------------------------
|
||||
let minX = W, minY = H, maxX = -1, maxY = -1;
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
if (alpha[y * W + x] > 8) {
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`bbox: x ${minX}..${maxX} y ${minY}..${maxY}`);
|
||||
|
||||
const rgba = Buffer.alloc(W * H * 4);
|
||||
for (let i = 0; i < W * H; i++) {
|
||||
rgba[i * 4] = data[i * C];
|
||||
rgba[i * 4 + 1] = data[i * C + 1];
|
||||
rgba[i * 4 + 2] = data[i * C + 2];
|
||||
rgba[i * 4 + 3] = alpha[i];
|
||||
}
|
||||
|
||||
const pad = 4;
|
||||
const left = Math.max(0, minX - pad);
|
||||
const top = Math.max(0, minY - pad);
|
||||
await sharp(rgba, { raw: { width: W, height: H, channels: 4 } })
|
||||
.extract({
|
||||
left,
|
||||
top,
|
||||
width: Math.min(W - 1, maxX + pad) - left,
|
||||
height: Math.min(H - 1, maxY + pad) - top,
|
||||
})
|
||||
.png({ compressionLevel: 9 })
|
||||
.toFile(OUT);
|
||||
|
||||
console.log(`written ${OUT}`);
|
||||
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
@@ -0,0 +1,108 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import banner from '../assets/hero-banner.jpg';
|
||||
import { ABOUT, GENRES, CONTACT } from '../data/site';
|
||||
|
||||
const accentVar: Record<(typeof GENRES)[number]['accent'], string> = {
|
||||
magenta: 'var(--color-laser-magenta)',
|
||||
cyan: 'var(--color-laser-cyan)',
|
||||
gold: 'var(--color-gold-400)',
|
||||
violet: 'var(--color-laser-violet)',
|
||||
lime: 'var(--color-laser-lime)',
|
||||
};
|
||||
---
|
||||
|
||||
<section id="about" class="relative overflow-hidden py-24 sm:py-32">
|
||||
{/* Weicher Farbschimmer im Hintergrund */}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(60%_45%_at_85%_5%,--alpha(var(--color-gold-500)/10%),transparent_70%),radial-gradient(50%_40%_at_5%_60%,--alpha(var(--color-laser-violet)/10%),transparent_70%)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-5 sm:px-8">
|
||||
<div class="grid items-center gap-12 lg:grid-cols-2 lg:gap-16">
|
||||
<div class="relative" data-reveal>
|
||||
<div class="overflow-hidden rounded-3xl border border-white/10 shadow-[0_30px_80px_-20px_rgba(0,0,0,0.8)]">
|
||||
<Image
|
||||
src={banner}
|
||||
alt="DJ NIZZ Key Visual: Bühne mit Lasern und Crowd"
|
||||
widths={[640, 960, 1400]}
|
||||
sizes="(min-width: 1024px) 40rem, 92vw"
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Kleiner Akzent, der aus der Karte herausragt */}
|
||||
<div
|
||||
class="absolute -bottom-6 -left-2 hidden rounded-2xl border border-white/10 bg-ink-900/90 px-5 py-4 backdrop-blur-md sm:-left-6 sm:block"
|
||||
>
|
||||
<p class="font-display text-2xl text-gold-400">{GENRES.length}</p>
|
||||
<p class="mt-0.5 text-xs tracking-[0.16em] text-white/55 uppercase">Genres, ein Flow</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-reveal data-reveal-delay="120">
|
||||
<p class="text-xs font-semibold tracking-[0.28em] text-gold-400 uppercase">{ABOUT.kicker}</p>
|
||||
<h2 class="mt-4 font-display text-[clamp(2rem,5vw,3.25rem)] leading-[1.05] tracking-[-0.02em] text-white">
|
||||
{ABOUT.headline}
|
||||
</h2>
|
||||
|
||||
<p class="mt-6 text-balance-pretty text-lg leading-relaxed text-white/80">{ABOUT.lead}</p>
|
||||
|
||||
{
|
||||
ABOUT.paragraphs.map((text) => (
|
||||
<p class="mt-4 text-balance-pretty leading-relaxed text-white/60">{text}</p>
|
||||
))
|
||||
}
|
||||
|
||||
<dl class="mt-10 grid grid-cols-2 gap-x-6 gap-y-6 border-t border-white/10 pt-8">
|
||||
{
|
||||
ABOUT.facts.map((fact) => (
|
||||
<div>
|
||||
<dt class="text-xs tracking-[0.16em] text-white/45 uppercase">{fact.label}</dt>
|
||||
<dd class="mt-1.5 font-medium text-white">{fact.value}</dd>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</dl>
|
||||
|
||||
<a
|
||||
href={`mailto:${CONTACT.email}?subject=Booking-Anfrage`}
|
||||
class="group mt-10 inline-flex items-center gap-2 font-semibold text-gold-400 transition-colors hover:text-gold-300"
|
||||
>
|
||||
Termin anfragen
|
||||
<svg viewBox="0 0 20 20" class="h-4 w-4 transition-transform duration-300 group-hover:translate-x-1" fill="currentColor" aria-hidden="true">
|
||||
<path d="M10.5 3.5 17 10l-6.5 6.5-1.4-1.4 4.1-4.1H3v-2h10.2L9.1 4.9z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-24 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{
|
||||
GENRES.map((genre, i) => (
|
||||
<article
|
||||
class="group relative overflow-hidden rounded-2xl border border-white/10 bg-ink-900/50 p-6 transition duration-300 hover:-translate-y-1 hover:border-white/20 hover:bg-ink-850/70"
|
||||
data-reveal
|
||||
data-reveal-delay={i * 90}
|
||||
>
|
||||
<span
|
||||
class="absolute inset-x-0 top-0 h-px opacity-60 transition-opacity duration-300 group-hover:opacity-100"
|
||||
style={`background:linear-gradient(to right, transparent, ${accentVar[genre.accent]}, transparent);`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
class="absolute -top-16 -right-16 h-40 w-40 rounded-full opacity-0 blur-3xl transition-opacity duration-500 group-hover:opacity-30"
|
||||
style={`background:${accentVar[genre.accent]};`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3 class="font-display text-xl text-white">{genre.name}</h3>
|
||||
<p class="mt-3 text-sm leading-relaxed text-white/55">{genre.description}</p>
|
||||
</article>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import logo from '../assets/logo-white.png';
|
||||
import SocialIcon from './SocialIcon.astro';
|
||||
import { SITE, CONTACT, SOCIALS, NAV, GENRES } from '../data/site';
|
||||
|
||||
// Nur Profile anzeigen, fuer die in site.ts auch eine URL hinterlegt ist.
|
||||
const socials = SOCIALS.filter((s) => s.url);
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
const names = GENRES.map((g) => g.name);
|
||||
const genreList = `${names.slice(0, -1).join(', ')} und ${names.at(-1)}`;
|
||||
---
|
||||
|
||||
<footer class="relative border-t border-white/10 bg-ink-950">
|
||||
<div class="mx-auto max-w-7xl px-5 py-16 sm:px-8 sm:py-20">
|
||||
<div class="flex flex-col gap-12 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="max-w-sm">
|
||||
<div class="flex items-center gap-3">
|
||||
<Image src={logo} alt="" width={40} height={40} loading="lazy" class="h-10 w-10" />
|
||||
<span class="font-display text-xl text-white">DJ NIZZ</span>
|
||||
</div>
|
||||
<p class="mt-5 leading-relaxed text-white/55">
|
||||
{genreList} für Clubs, Festivals und Privatevents in {SITE.region} und Umgebung.
|
||||
</p>
|
||||
|
||||
{
|
||||
socials.length > 0 && (
|
||||
<ul class="mt-7 flex items-center gap-3">
|
||||
{socials.map((social) => (
|
||||
<li>
|
||||
<a
|
||||
href={social.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer me"
|
||||
class="grid h-11 w-11 place-items-center rounded-full border border-white/12 text-white/65 transition duration-300 hover:border-gold-400/60 hover:text-gold-400"
|
||||
aria-label={social.name}
|
||||
>
|
||||
<SocialIcon name={social.icon} />
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="grid gap-10 sm:grid-cols-2 sm:gap-16">
|
||||
<div>
|
||||
<h2 class="text-xs font-semibold tracking-[0.2em] text-white/40 uppercase">Navigation</h2>
|
||||
<ul class="mt-4 space-y-3">
|
||||
{
|
||||
NAV.map((item) => (
|
||||
<li>
|
||||
<a href={item.href} class="text-white/70 transition-colors hover:text-gold-400">
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-xs font-semibold tracking-[0.2em] text-white/40 uppercase">Booking</h2>
|
||||
<ul class="mt-4 space-y-3">
|
||||
<li>
|
||||
<a
|
||||
href={`mailto:${CONTACT.email}?subject=Booking-Anfrage`}
|
||||
class="text-white/70 transition-colors hover:text-gold-400"
|
||||
>
|
||||
{CONTACT.email}
|
||||
</a>
|
||||
</li>
|
||||
{
|
||||
CONTACT.phone && (
|
||||
<li>
|
||||
<a
|
||||
href={`tel:${CONTACT.phone.replace(/[^+\d]/g, '')}`}
|
||||
class="text-white/70 transition-colors hover:text-gold-400"
|
||||
>
|
||||
{CONTACT.phone}
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-14 flex flex-col gap-4 border-t border-white/10 pt-8 text-sm text-white/40 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<p>© {year} {SITE.name}. Alle Rechte vorbehalten.</p>
|
||||
<ul class="flex items-center gap-6">
|
||||
<li><a href="/impressum" class="transition-colors hover:text-white/70">Impressum</a></li>
|
||||
<li><a href="/datenschutz" class="transition-colors hover:text-white/70">Datenschutz</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import logo from '../assets/logo-white.png';
|
||||
import { SITE, CONTACT, NAV } from '../data/site';
|
||||
---
|
||||
|
||||
<header
|
||||
id="site-header"
|
||||
class="fixed inset-x-0 top-0 z-50 transition-[background-color,backdrop-filter,border-color] duration-300 border-b border-transparent data-[scrolled]:border-white/10 data-[scrolled]:bg-ink-950/80 data-[scrolled]:backdrop-blur-xl"
|
||||
>
|
||||
<div class="mx-auto flex h-16 max-w-7xl items-center justify-between px-5 sm:h-20 sm:px-8">
|
||||
<a href="#start" class="group flex items-center gap-3" aria-label={`${SITE.name} Startseite`}>
|
||||
<Image
|
||||
src={logo}
|
||||
alt=""
|
||||
width={44}
|
||||
height={44}
|
||||
loading="eager"
|
||||
class="h-9 w-9 transition-transform duration-300 group-hover:scale-110 sm:h-11 sm:w-11"
|
||||
/>
|
||||
<span class="font-display text-lg tracking-tight text-white sm:text-xl">DJ NIZZ</span>
|
||||
</a>
|
||||
|
||||
<nav class="hidden items-center gap-8 md:flex" aria-label="Hauptnavigation">
|
||||
{
|
||||
NAV.map((item) => (
|
||||
<a
|
||||
href={item.href}
|
||||
class="relative text-sm font-medium text-white/70 transition-colors hover:text-white after:absolute after:-bottom-1.5 after:left-0 after:h-px after:w-0 after:bg-gold-400 after:transition-all after:duration-300 hover:after:w-full"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
<a
|
||||
href={`mailto:${CONTACT.email}?subject=Booking-Anfrage`}
|
||||
class="rounded-full bg-gold-400 px-5 py-2.5 text-sm font-semibold text-ink-950 transition hover:bg-gold-300 hover:shadow-[0_0_24px_-4px] hover:shadow-gold-400/60"
|
||||
>
|
||||
Booking
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<button
|
||||
id="menu-toggle"
|
||||
type="button"
|
||||
class="relative z-50 grid h-11 w-11 place-items-center rounded-lg text-white md:hidden"
|
||||
aria-label="Menü öffnen"
|
||||
aria-expanded="false"
|
||||
aria-controls="mobile-menu"
|
||||
>
|
||||
<span class="sr-only">Menü</span>
|
||||
<svg viewBox="0 0 24 24" class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path class="origin-center transition-transform duration-300" id="bar-top" d="M4 7h16" />
|
||||
<path class="transition-opacity duration-200" id="bar-mid" d="M4 12h16" />
|
||||
<path class="origin-center transition-transform duration-300" id="bar-bot" d="M4 17h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="mobile-menu"
|
||||
class="invisible fixed inset-0 -z-10 flex flex-col items-center justify-center gap-8 bg-ink-950/97 opacity-0 backdrop-blur-xl transition-opacity duration-300 data-[open]:visible data-[open]:opacity-100 md:hidden"
|
||||
>
|
||||
{
|
||||
NAV.map((item) => (
|
||||
<a href={item.href} data-menu-link class="font-display text-3xl text-white/90 transition-colors hover:text-gold-400">
|
||||
{item.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
<a
|
||||
href={`mailto:${CONTACT.email}?subject=Booking-Anfrage`}
|
||||
data-menu-link
|
||||
class="mt-2 rounded-full bg-gold-400 px-8 py-3.5 font-display text-lg text-ink-950"
|
||||
>
|
||||
Booking
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<script>
|
||||
const header = document.getElementById('site-header')!;
|
||||
const toggle = document.getElementById('menu-toggle')!;
|
||||
const menu = document.getElementById('mobile-menu')!;
|
||||
const barTop = document.getElementById('bar-top')!;
|
||||
const barMid = document.getElementById('bar-mid')!;
|
||||
const barBot = document.getElementById('bar-bot')!;
|
||||
|
||||
// Header wird ab dem ersten Scrollen undurchsichtig
|
||||
const onScroll = () => {
|
||||
if (window.scrollY > 24) header.setAttribute('data-scrolled', '');
|
||||
else header.removeAttribute('data-scrolled');
|
||||
};
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
|
||||
const setMenu = (open: boolean) => {
|
||||
menu.toggleAttribute('data-open', open);
|
||||
toggle.setAttribute('aria-expanded', String(open));
|
||||
toggle.setAttribute('aria-label', open ? 'Menü schließen' : 'Menü öffnen');
|
||||
document.body.style.overflow = open ? 'hidden' : '';
|
||||
barTop.style.transform = open ? 'translateY(5px) rotate(45deg)' : '';
|
||||
barBot.style.transform = open ? 'translateY(-5px) rotate(-45deg)' : '';
|
||||
barMid.style.opacity = open ? '0' : '';
|
||||
};
|
||||
|
||||
toggle.addEventListener('click', () => setMenu(!menu.hasAttribute('data-open')));
|
||||
menu.querySelectorAll('[data-menu-link]').forEach((link) => link.addEventListener('click', () => setMenu(false)));
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && menu.hasAttribute('data-open')) setMenu(false);
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import club from '../assets/hero-club.jpg';
|
||||
import nizz from '../assets/nizz-cutout.png';
|
||||
import { CONTACT, GENRES } from '../data/site';
|
||||
|
||||
// Laserstrahlen: Position in %, Start-/Endwinkel, Farbe, Startverzoegerung
|
||||
const beams = [
|
||||
{ x: 18, from: '-26deg', to: '-13deg', color: 'var(--color-laser-cyan)', delay: '0s' },
|
||||
{ x: 32, from: '-12deg', to: '-3deg', color: 'var(--color-laser-magenta)', delay: '-2.6s' },
|
||||
{ x: 47, from: '9deg', to: '19deg', color: 'var(--color-gold-400)', delay: '-5.1s' },
|
||||
{ x: 61, from: '22deg', to: '11deg', color: 'var(--color-laser-violet)', delay: '-1.3s' },
|
||||
{ x: 74, from: '31deg', to: '20deg', color: 'var(--color-laser-cyan)', delay: '-6.7s' },
|
||||
];
|
||||
|
||||
const genreLine = GENRES.map((g) => g.name).join(' • ');
|
||||
---
|
||||
|
||||
<section id="start" class="relative isolate min-h-svh overflow-hidden">
|
||||
{/* Ebene 1: Club-Ausschnitt des Original-Artworks, langsam driftend */}
|
||||
<div class="absolute inset-0 -z-30" aria-hidden="true">
|
||||
<Image
|
||||
src={club}
|
||||
alt=""
|
||||
widths={[640, 1024, 1600, 2000]}
|
||||
sizes="100vw"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
class="h-full w-full animate-drift object-cover object-center opacity-60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ebene 2: animierte Laser */}
|
||||
<div class="absolute inset-x-0 top-0 -z-20 h-[125%] overflow-hidden" aria-hidden="true">
|
||||
{
|
||||
beams.map((b) => (
|
||||
<span
|
||||
class="absolute top-[-12%] h-full w-px origin-top animate-sweep blur-[1.5px] mix-blend-screen"
|
||||
style={`left:${b.x}%;--beam-from:${b.from};--beam-to:${b.to};animation-delay:${b.delay};background:linear-gradient(to bottom, ${b.color} 0%, ${b.color} 35%, transparent 100%);`}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Ebene 3: Abdunkeln, damit die Typo sicher lesbar bleibt */}
|
||||
<div
|
||||
class="absolute inset-0 -z-10 bg-[radial-gradient(120%_85%_at_18%_35%,transparent_0%,--alpha(var(--color-ink-950)/72%)_70%),linear-gradient(to_bottom,--alpha(var(--color-ink-950)/85%)_0%,--alpha(var(--color-ink-950)/25%)_38%,var(--color-ink-950)_97%)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative mx-auto flex min-h-svh max-w-7xl flex-col justify-center px-5 pt-28 pb-36 sm:px-8 lg:pb-32"
|
||||
>
|
||||
{/*
|
||||
Die Figur liegt bewusst absolut und nicht im Textfluss: als Grid-Spalte
|
||||
wuerde ihre Eigenhoehe (649x1919 px) die Zeile auf ueber 1200 px aufziehen
|
||||
und Ueberschrift samt Buttons unter die Falz druecken.
|
||||
*/}
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 right-[-8%] bottom-12 -z-5 flex w-[72%] items-end opacity-25 sm:right-0 sm:w-[52%] sm:opacity-35 lg:right-[4%] lg:bottom-14 lg:z-0 lg:w-auto lg:opacity-100"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="relative ml-auto w-full max-w-[26rem] lg:h-full lg:w-auto lg:max-w-none">
|
||||
<div
|
||||
class="absolute inset-x-[-30%] bottom-[6%] -z-10 aspect-square rounded-full bg-[radial-gradient(closest-side,--alpha(var(--color-gold-400)/22%),transparent)] blur-2xl"
|
||||
>
|
||||
</div>
|
||||
<Image
|
||||
src={nizz}
|
||||
alt=""
|
||||
widths={[420, 649]}
|
||||
sizes="(min-width: 1024px) 26rem, 60vw"
|
||||
loading="eager"
|
||||
class="h-auto w-full object-contain drop-shadow-[0_25px_45px_rgba(0,0,0,0.55)] [mask-image:linear-gradient(to_top,transparent_0%,black_7%)] lg:h-full lg:w-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-2xl lg:max-w-[54%]">
|
||||
<p
|
||||
class="mb-5 inline-flex items-center gap-2.5 rounded-full border border-white/15 bg-white/5 px-4 py-1.5 text-xs font-medium tracking-[0.2em] text-white/70 uppercase backdrop-blur-sm"
|
||||
>
|
||||
<span class="relative flex h-1.5 w-1.5">
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-gold-400 opacity-75"></span>
|
||||
<span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-gold-400"></span>
|
||||
</span>
|
||||
DJ & Producer · Österreich
|
||||
</p>
|
||||
|
||||
<h1
|
||||
class="bg-gradient-to-b from-white via-white to-gold-300 bg-clip-text font-display text-[clamp(3.75rem,13vw,10rem)] leading-[0.84] tracking-[-0.035em] text-transparent drop-shadow-[0_0_60px_rgba(240,191,88,0.18)]"
|
||||
>
|
||||
DJ NIZZ
|
||||
</h1>
|
||||
|
||||
<p class="mt-6 text-sm font-semibold tracking-[0.28em] text-white/85 uppercase sm:text-base">
|
||||
{genreLine}
|
||||
</p>
|
||||
|
||||
<p class="mt-6 max-w-lg text-balance-pretty text-base leading-relaxed text-white/65 sm:text-lg">
|
||||
Sets, die den Abend aufbauen statt ihn abzuspielen — von House über Techno bis Hardstyle.
|
||||
</p>
|
||||
|
||||
<div class="mt-10 flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
|
||||
<a
|
||||
href={`mailto:${CONTACT.email}?subject=Booking-Anfrage`}
|
||||
class="group inline-flex items-center justify-center gap-2 rounded-full bg-gold-400 px-7 py-4 font-semibold text-ink-950 transition duration-300 hover:bg-gold-300 hover:shadow-[0_0_36px_-6px] hover:shadow-gold-400/70"
|
||||
>
|
||||
Booking anfragen
|
||||
<svg viewBox="0 0 20 20" class="h-4 w-4 transition-transform duration-300 group-hover:translate-x-1" fill="currentColor" aria-hidden="true">
|
||||
<path d="M10.5 3.5 17 10l-6.5 6.5-1.4-1.4 4.1-4.1H3v-2h10.2L9.1 4.9z" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="#about"
|
||||
class="inline-flex items-center justify-center rounded-full border border-white/20 px-7 py-4 font-semibold text-white/90 backdrop-blur-sm transition duration-300 hover:border-white/45 hover:bg-white/5"
|
||||
>
|
||||
Mehr über mich
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scroll-Hinweis */}
|
||||
<a
|
||||
href="#about"
|
||||
class="absolute bottom-20 left-1/2 z-10 hidden -translate-x-1/2 animate-cue text-white/50 transition-colors hover:text-white sm:block"
|
||||
aria-label="Weiter zu Über mich"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
||||
<path d="M12 4v15m0 0-6-6m6 6 6-6" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
{/* Genre-Laufband */}
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 z-10 border-y border-white/10 bg-ink-950/60 py-3.5 backdrop-blur-md"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="flex w-max animate-marquee">
|
||||
{
|
||||
[0, 1].map(() => (
|
||||
<div class="flex shrink-0 items-center">
|
||||
{GENRES.map((g) => (
|
||||
<span class="flex items-center">
|
||||
<span class="px-6 font-display text-sm tracking-[0.18em] text-white/45 uppercase sm:text-base">
|
||||
{g.name}
|
||||
</span>
|
||||
<span class="h-1 w-1 rounded-full bg-gold-400/70" />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
import type { SocialIcon } from '../data/site';
|
||||
|
||||
interface Props {
|
||||
name: SocialIcon;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { name, class: className = 'h-5 w-5' } = Astro.props;
|
||||
---
|
||||
|
||||
{/* Schlichte Linien-Icons - konsistenter Look, keine externen Icon-Pakete. */}
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{
|
||||
name === 'instagram' && (
|
||||
<>
|
||||
<rect x="3" y="3" width="18" height="18" rx="5.4" />
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<circle cx="17.2" cy="6.8" r="1.1" fill="currentColor" stroke="none" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
name === 'youtube' && (
|
||||
<>
|
||||
<rect x="2" y="5" width="20" height="14" rx="4.2" />
|
||||
<path d="M10.2 9.1v5.8L15.3 12z" fill="currentColor" stroke="none" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
name === 'spotify' && (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9.2" />
|
||||
<path d="M7.2 9.3c3.1-.9 6.6-.5 9.3 1.2" />
|
||||
<path d="M8 12.3c2.5-.7 5.3-.4 7.5 1" />
|
||||
<path d="M8.8 15.2c2-.5 4.1-.3 5.9.8" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
name === 'soundcloud' && (
|
||||
<>
|
||||
<path d="M3.4 14.6v2.6M6.2 12.4v4.8M9 10.2v7M11.8 8v9.2" />
|
||||
<path d="M14.6 17.2h4.1a2.9 2.9 0 0 0 .3-5.8 4.6 4.6 0 0 0-4.4-3.4V17.2z" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
name === 'tiktok' && (
|
||||
<>
|
||||
<path d="M14.2 3.4v10.3a3.9 3.9 0 1 1-3.9-3.9" />
|
||||
<path d="M14.2 3.4c.5 2.5 2.2 4 4.6 4.2" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
</svg>
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Zentrale Inhalts- und Konfigurationsdatei.
|
||||
*
|
||||
* Praktisch alle Texte, Links und Kontaktdaten der Seite stehen hier drin -
|
||||
* fuer Aenderungen am Inhalt musst du keine Komponente anfassen.
|
||||
*
|
||||
* Achtung: `SITE.url` muss mit `site` in astro.config.mjs uebereinstimmen.
|
||||
*/
|
||||
|
||||
export const SITE = {
|
||||
name: 'DJ NIZZ',
|
||||
url: 'https://djnizz.at',
|
||||
/** Erscheint im Browser-Tab und in Suchergebnissen. */
|
||||
title: 'DJ NIZZ - Hardstyle, Techno, Drum & Bass, House & Commerce DJ',
|
||||
description:
|
||||
'DJ NIZZ - Hardstyle, Techno, Drum & Bass, House und Commerce. Sets, die den Floor nicht mehr loslassen. Jetzt für Clubs, Festivals und Privatevents buchen.',
|
||||
locale: 'de-AT',
|
||||
/** Wird fuer strukturierte Daten und den Footer verwendet. */
|
||||
region: 'Österreich',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Wird an allen Booking-Buttons, im Footer und im Impressum verwendet.
|
||||
* Bewusst ohne `as const`: sonst haette `phone` den Literaltyp `''` und
|
||||
* TypeScript wuerde den Zweig "Telefonnummer anzeigen" als unerreichbar werten.
|
||||
*/
|
||||
export const CONTACT: { email: string; phone: string } = {
|
||||
email: 'business.djnizz@gmail.com',
|
||||
/** Leer lassen, wenn die Telefonnummer nicht oeffentlich stehen soll. */
|
||||
phone: '',
|
||||
};
|
||||
|
||||
/** Angaben fuer Impressum (§ 25 MedienG, § 5 ECG) und Datenschutzerklaerung. */
|
||||
export const LEGAL = {
|
||||
name: 'Nicolas Thomas Gruber',
|
||||
street: 'Seegasse 10',
|
||||
zip: '7063',
|
||||
city: 'Oggau am Neusiedler See',
|
||||
country: 'Österreich',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* TODO anpassen: eigene Profil-Links eintragen.
|
||||
* Eintraege mit leerem `url` werden auf der Seite automatisch weggelassen,
|
||||
* du kannst also alles stehen lassen, was du noch nicht hast.
|
||||
*/
|
||||
export const SOCIALS: { name: string; url: string; icon: SocialIcon }[] = [
|
||||
{ name: 'Instagram', url: '', icon: 'instagram' },
|
||||
{ name: 'SoundCloud', url: '', icon: 'soundcloud' },
|
||||
{ name: 'Spotify', url: '', icon: 'spotify' },
|
||||
{ name: 'YouTube', url: '', icon: 'youtube' },
|
||||
{ name: 'TikTok', url: '', icon: 'tiktok' },
|
||||
];
|
||||
|
||||
export type SocialIcon = 'instagram' | 'soundcloud' | 'spotify' | 'youtube' | 'tiktok';
|
||||
|
||||
/** Reihenfolge bestimmt die Anzeige im Hero-Marquee und in den Genre-Karten. */
|
||||
export const GENRES = [
|
||||
{
|
||||
name: 'Hardstyle',
|
||||
accent: 'magenta',
|
||||
description:
|
||||
'Harte Kicks, euphorische Melodien, Screeches, die im Nacken sitzen. Der Moment, in dem der Floor kollektiv abhebt.',
|
||||
},
|
||||
{
|
||||
name: 'Techno',
|
||||
accent: 'cyan',
|
||||
description:
|
||||
'Treibend, hypnotisch, kompromisslos. Lange Spannungsbögen statt schneller Effekte - gebaut für die späten Stunden.',
|
||||
},
|
||||
{
|
||||
name: 'Drum & Bass',
|
||||
accent: 'lime',
|
||||
description:
|
||||
'Breakbeats bei 174 BPM, Bass, der im Brustkorb sitzt. Der Gangwechsel, mit dem ein Set nochmal komplett die Richtung dreht.',
|
||||
},
|
||||
{
|
||||
name: 'House',
|
||||
accent: 'gold',
|
||||
description:
|
||||
'Groove zuerst. Warme Basslines und Vocals, die die Menge in Bewegung halten, ohne sie zu überfahren.',
|
||||
},
|
||||
{
|
||||
name: 'Commerce',
|
||||
accent: 'violet',
|
||||
description:
|
||||
'Die Hits, die jeder mitsingt - neu gedacht, sauber gemixt und so eingebaut, dass sie den Set-Flow tragen statt ihn zu brechen.',
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* TODO anpassen: Das ist Platzhaltertext ueber deinen Sound - keine
|
||||
* Biografie mit echten Daten. Schreib hier rein, was wirklich stimmt
|
||||
* (seit wann du auflegst, Residencies, groesste Gigs, Releases).
|
||||
*/
|
||||
export const ABOUT = {
|
||||
kicker: 'Über mich',
|
||||
headline: 'Fünf Genres, ein Set',
|
||||
lead:
|
||||
'Ich lege auf, was den Raum trägt - nicht was gerade in der Charts-Playlist steht. Hardstyle, Techno, Drum & Bass, House und Commerce sind für mich keine getrennten Schubladen, sondern fünf Werkzeuge für denselben Abend.',
|
||||
paragraphs: [
|
||||
'Ein Set beginnt selten dort, wo es endet. Ich baue den Abend auf: groovige House-Nummern zum Ankommen, ein Techno-Mittelteil, der die Spannung hält, ein Drum-&-Bass-Break als Richtungswechsel und Hardstyle für den Punkt, an dem der Floor wirklich explodiert. Dazwischen die Tracks, die jeder kennt - so eingebaut, dass sie den Flow tragen.',
|
||||
'Was mir wichtig ist: Ich spiele für die Leute vor mir, nicht für die Kamera. Wenn ein Übergang sitzt und man das an 400 Händen gleichzeitig sieht, ist der Abend gelungen.',
|
||||
],
|
||||
/** Kurze Eckdaten neben dem Text. Text frei anpassbar. */
|
||||
facts: [
|
||||
{ label: 'Basis', value: 'Österreich' },
|
||||
{ label: 'Setlänge', value: '1-4 h' },
|
||||
{ label: 'Formate', value: 'Club, Festival, Private' },
|
||||
{ label: 'Technik', value: 'Pioneer CDJ / Rekordbox' },
|
||||
],
|
||||
} as const;
|
||||
|
||||
/** Navigation. Weitere Sektionen (Musik, Termine) hier ergaenzen. */
|
||||
export const NAV = [
|
||||
{ label: 'Start', href: '#start' },
|
||||
{ label: 'Über mich', href: '#about' },
|
||||
] as const;
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
import { SITE, CONTACT, SOCIALS, GENRES } from '../data/site';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const { title = SITE.title, description = SITE.description } = Astro.props;
|
||||
|
||||
const canonical = new URL(Astro.url.pathname, Astro.site ?? SITE.url).href;
|
||||
const ogImage = new URL('/og-image.jpg', Astro.site ?? SITE.url).href;
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'MusicGroup',
|
||||
name: SITE.name,
|
||||
url: SITE.url,
|
||||
image: ogImage,
|
||||
description: SITE.description,
|
||||
genre: GENRES.map((g) => g.name),
|
||||
email: CONTACT.email,
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressCountry: 'AT',
|
||||
},
|
||||
sameAs: SOCIALS.filter((s) => s.url).map((s) => s.url),
|
||||
};
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="de-AT" class="scheme-dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={canonical} />
|
||||
|
||||
<meta name="theme-color" content="#05070f" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
|
||||
<link rel="icon" href="/favicon-32.png" sizes="32x32" type="image/png" />
|
||||
<link rel="icon" href="/favicon-192.png" sizes="192x192" type="image/png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content={SITE.name} />
|
||||
<meta property="og:locale" content="de_AT" />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:image" content={ogImage} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content={`${SITE.name} - Hardstyle, Techno, House, Commerce`} />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={ogImage} />
|
||||
|
||||
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} is:inline />
|
||||
</head>
|
||||
|
||||
<body class="min-h-screen antialiased">
|
||||
<a
|
||||
href="#about"
|
||||
class="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-100 focus:rounded-lg focus:bg-gold-400 focus:px-4 focus:py-2 focus:font-semibold focus:text-ink-950"
|
||||
>
|
||||
Zum Inhalt springen
|
||||
</a>
|
||||
<slot />
|
||||
|
||||
<script>
|
||||
// Sanftes Einblenden beim Scrollen. Laeuft nur, wenn JS aktiv ist und der
|
||||
// Nutzer keine reduzierte Bewegung angefordert hat - ohne das Skript
|
||||
// bleibt der Inhalt schlicht sofort sichtbar.
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const targets = document.querySelectorAll<HTMLElement>('[data-reveal]');
|
||||
|
||||
if (!reduced && targets.length > 0 && 'IntersectionObserver' in window) {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
const el = entry.target as HTMLElement;
|
||||
const delay = Number(el.dataset.revealDelay ?? 0);
|
||||
window.setTimeout(() => (el.dataset.reveal = 'shown'), delay);
|
||||
observer.unobserve(el);
|
||||
}
|
||||
},
|
||||
{ rootMargin: '0px 0px -12% 0px', threshold: 0.1 },
|
||||
);
|
||||
|
||||
for (const el of targets) {
|
||||
el.dataset.reveal = 'pending';
|
||||
observer.observe(el);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
import Base from './Base.astro';
|
||||
import Header from '../components/Header.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import { SITE } from '../data/site';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const { title, description } = Astro.props;
|
||||
---
|
||||
|
||||
<Base title={`${title} · ${SITE.name}`} description={description}>
|
||||
<Header />
|
||||
|
||||
<main class="mx-auto max-w-3xl px-5 pt-32 pb-24 sm:px-8 sm:pt-40 sm:pb-32">
|
||||
<a href="/" class="text-sm text-white/50 transition-colors hover:text-gold-400">← Zurück zur Startseite</a>
|
||||
|
||||
<h1 class="mt-6 font-display text-[clamp(2rem,5vw,3rem)] leading-tight tracking-[-0.02em] text-white">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
<div
|
||||
class="mt-10 space-y-6 leading-relaxed text-white/65 [&_a]:text-gold-400 [&_a:hover]:text-gold-300 [&_h2]:mt-12 [&_h2]:font-display [&_h2]:text-xl [&_h2]:text-white [&_li]:marker:text-white/30 [&_p+p]:mt-4 [&_strong]:text-white/90 [&_ul]:list-disc [&_ul]:space-y-2 [&_ul]:pl-5"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</Base>
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import Header from '../components/Header.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import { SITE } from '../data/site';
|
||||
---
|
||||
|
||||
<Base title={`Seite nicht gefunden · ${SITE.name}`} description="Diese Seite existiert nicht.">
|
||||
<Header />
|
||||
|
||||
<main class="mx-auto flex min-h-svh max-w-3xl flex-col items-center justify-center px-5 py-32 text-center sm:px-8">
|
||||
<p class="font-display text-[clamp(5rem,18vw,10rem)] leading-none text-white/10">404</p>
|
||||
<h1 class="mt-2 font-display text-3xl text-white sm:text-4xl">Hier läuft nichts</h1>
|
||||
<p class="mt-4 max-w-md text-white/60">
|
||||
Diese Seite gibt es nicht (mehr). Zurück zum Start – dort spielt die Musik.
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
class="mt-9 rounded-full bg-gold-400 px-7 py-4 font-semibold text-ink-950 transition duration-300 hover:bg-gold-300 hover:shadow-[0_0_36px_-6px] hover:shadow-gold-400/70"
|
||||
>
|
||||
Zur Startseite
|
||||
</a>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</Base>
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
import Legal from '../layouts/Legal.astro';
|
||||
import { SITE, CONTACT, LEGAL } from '../data/site';
|
||||
---
|
||||
|
||||
{/*
|
||||
Diese Erklaerung beschreibt exakt das, was die Seite aktuell tut:
|
||||
keine Cookies, kein Tracking, keine externen Requests (Schriften sind
|
||||
selbst gehostet), kein Formular. Wenn spaeter ein Booking-Formular,
|
||||
Analytics oder eingebettete Player (Spotify/YouTube) dazukommen, muss
|
||||
dieser Text entsprechend erweitert werden.
|
||||
*/}
|
||||
|
||||
<Legal
|
||||
title="Datenschutzerklärung"
|
||||
description={`Datenschutzerklärung für ${SITE.url} - keine Cookies, kein Tracking.`}
|
||||
>
|
||||
<h2>Verantwortlicher</h2>
|
||||
<p>
|
||||
{LEGAL.name}<br />
|
||||
{LEGAL.street}, {LEGAL.zip} {LEGAL.city}, {LEGAL.country}<br />
|
||||
E-Mail: <a href={`mailto:${CONTACT.email}`}>{CONTACT.email}</a>
|
||||
</p>
|
||||
|
||||
<h2>Grundsatz</h2>
|
||||
<p>
|
||||
Diese Website setzt <strong>keine Cookies</strong>, bindet <strong>keine Analyse- oder
|
||||
Tracking-Dienste</strong> ein und lädt <strong>keine Inhalte von Drittanbietern</strong> nach.
|
||||
Auch die verwendeten Schriftarten werden vom eigenen Server ausgeliefert – es entsteht also
|
||||
keine Verbindung zu externen Anbietern wie Google Fonts. Es besteht daher kein Cookie-Banner und
|
||||
keine Einwilligungspflicht.
|
||||
</p>
|
||||
|
||||
<h2>Server-Logfiles</h2>
|
||||
<p>
|
||||
Beim Aufruf der Seite verarbeitet der Webserver technisch notwendige Zugriffsdaten: gekürzte
|
||||
IP-Adresse, Datum und Uhrzeit, aufgerufene Adresse, übertragene Datenmenge, Referrer und
|
||||
Browserkennung. Diese Daten sind für die Auslieferung der Seite und für die Abwehr von Angriffen
|
||||
erforderlich.
|
||||
</p>
|
||||
<p>
|
||||
Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse am sicheren und stabilen
|
||||
Betrieb). Eine Zusammenführung mit anderen Datenquellen findet nicht statt. Die Logdateien
|
||||
werden automatisch nach spätestens 14 Tagen gelöscht.
|
||||
</p>
|
||||
|
||||
<h2>Hosting</h2>
|
||||
<p>
|
||||
Die Website läuft auf einem eigenen Server bei Oracle Cloud Infrastructure (Oracle Corporation)
|
||||
innerhalb der EU. Der Anbieter verarbeitet Daten ausschließlich weisungsgebunden als
|
||||
Auftragsverarbeiter im Sinne des Art. 28 DSGVO.
|
||||
</p>
|
||||
|
||||
<h2>Kontaktaufnahme per E-Mail</h2>
|
||||
<p>
|
||||
Wenn Sie mich über die angegebene Adresse kontaktieren, werden Ihre Angaben ausschließlich zur
|
||||
Bearbeitung der Anfrage verwendet. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO
|
||||
(vorvertragliche Maßnahmen) bzw. Art. 6 Abs. 1 lit. f DSGVO. Die Daten werden gelöscht, sobald
|
||||
sie nicht mehr benötigt werden und keine gesetzlichen Aufbewahrungspflichten entgegenstehen.
|
||||
</p>
|
||||
<p>
|
||||
Hinweis: Die Buchungsanfragen laufen über eine Gmail-Adresse. Für die Verarbeitung in diesem
|
||||
Postfach ist Google Ireland Limited als Anbieter des E-Mail-Dienstes mitverantwortlich.
|
||||
</p>
|
||||
|
||||
<h2>Ihre Rechte</h2>
|
||||
<p>
|
||||
Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung,
|
||||
Datenübertragbarkeit und Widerspruch. Wenden Sie sich dafür an die oben genannte Adresse.
|
||||
</p>
|
||||
<p>
|
||||
Wenn Sie der Ansicht sind, dass die Verarbeitung Ihrer Daten gegen das Datenschutzrecht
|
||||
verstößt, können Sie sich bei der österreichischen Datenschutzbehörde beschweren:
|
||||
<a href="https://www.dsb.gv.at" target="_blank" rel="noopener noreferrer">www.dsb.gv.at</a>.
|
||||
</p>
|
||||
</Legal>
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
import Legal from '../layouts/Legal.astro';
|
||||
import { SITE, CONTACT, LEGAL } from '../data/site';
|
||||
---
|
||||
|
||||
{/*
|
||||
Offenlegung nach § 25 MedienG und Informationspflicht nach § 5 ECG.
|
||||
Der gewerberechtliche Block entfaellt, weil keine Gewerbeberechtigung
|
||||
besteht. Falls spaeter eines angemeldet wird (bzw. regelmaessig mit
|
||||
Rechnung aufgelegt wird), muessen Gewerbeart, Behoerde und ggf. UID hier
|
||||
ergaenzt werden.
|
||||
*/}
|
||||
|
||||
<Legal title="Impressum" description={`Impressum und Offenlegung für ${SITE.url}`}>
|
||||
<h2>Medieninhaber, Herausgeber und für den Inhalt verantwortlich</h2>
|
||||
<p>
|
||||
{LEGAL.name}<br />
|
||||
{LEGAL.street}<br />
|
||||
{LEGAL.zip} {LEGAL.city}<br />
|
||||
{LEGAL.country}
|
||||
</p>
|
||||
|
||||
<h2>Kontakt</h2>
|
||||
<p>
|
||||
E-Mail: <a href={`mailto:${CONTACT.email}`}>{CONTACT.email}</a>
|
||||
{CONTACT.phone && (
|
||||
<>
|
||||
<br />Telefon: {CONTACT.phone}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<h2>Tätigkeit</h2>
|
||||
<p>
|
||||
Musikalische Darbietung als DJ. Es besteht keine Gewerbeberechtigung; die Tätigkeit wird nicht im
|
||||
Rahmen eines angemeldeten Gewerbes ausgeübt.
|
||||
</p>
|
||||
|
||||
<h2>Blattlinie</h2>
|
||||
<p>
|
||||
Information über die Tätigkeit von {SITE.name} als DJ sowie Kontaktmöglichkeit für
|
||||
Buchungsanfragen. Die Website verfolgt keine darüber hinausgehenden redaktionellen Zwecke.
|
||||
</p>
|
||||
|
||||
<h2>Haftung für Inhalte</h2>
|
||||
<p>
|
||||
Die Inhalte dieser Website wurden mit Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und
|
||||
Aktualität kann jedoch keine Gewähr übernommen werden. Für die Inhalte externer Links ist
|
||||
ausschließlich der jeweilige Anbieter verantwortlich; zum Zeitpunkt der Verlinkung waren keine
|
||||
Rechtsverstöße erkennbar.
|
||||
</p>
|
||||
|
||||
<h2>Urheberrecht</h2>
|
||||
<p>
|
||||
Sämtliche Inhalte dieser Website, insbesondere Texte, Grafiken, Artwork und Logo, sind
|
||||
urheberrechtlich geschützt. Eine Verwendung außerhalb der gesetzlich zulässigen Fälle bedarf der
|
||||
vorherigen schriftlichen Zustimmung.
|
||||
</p>
|
||||
</Legal>
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import Header from '../components/Header.astro';
|
||||
import Hero from '../components/Hero.astro';
|
||||
import About from '../components/About.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
---
|
||||
|
||||
<Base>
|
||||
<Header />
|
||||
<main>
|
||||
<Hero />
|
||||
<About />
|
||||
</main>
|
||||
<Footer />
|
||||
</Base>
|
||||
@@ -0,0 +1,111 @@
|
||||
/* Selbst gehostete Schriften - keine Requests an Google Fonts (DSGVO). */
|
||||
@import '@fontsource-variable/inter/index.css';
|
||||
@import '@fontsource/archivo-black/latin.css';
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Inter Variable', system-ui, -apple-system, sans-serif;
|
||||
--font-display: 'Archivo Black', 'Inter Variable', system-ui, sans-serif;
|
||||
|
||||
/* Markenpalette, abgeleitet aus dem DJ-NIZZ-Artwork */
|
||||
--color-ink-950: #05070f;
|
||||
--color-ink-900: #0b1020;
|
||||
--color-ink-850: #0f1526;
|
||||
--color-ink-800: #151d33;
|
||||
--color-ink-700: #1f2a45;
|
||||
|
||||
--color-gold-300: #f7d78a;
|
||||
--color-gold-400: #f0bf58;
|
||||
--color-gold-500: #e0a326;
|
||||
--color-gold-600: #b47c14;
|
||||
|
||||
--color-laser-cyan: #2ee6d6;
|
||||
--color-laser-magenta: #e879f9;
|
||||
--color-laser-violet: #8b7bfb;
|
||||
--color-laser-lime: #a3e635;
|
||||
|
||||
--animate-marquee: marquee 32s linear infinite;
|
||||
--animate-sweep: sweep 9s ease-in-out infinite;
|
||||
--animate-drift: drift 24s ease-in-out infinite alternate;
|
||||
--animate-cue: cue 2.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
@keyframes sweep {
|
||||
0%, 100% { transform: translateX(-50%) rotate(var(--beam-from)); opacity: 0.25; }
|
||||
50% { transform: translateX(-50%) rotate(var(--beam-to)); opacity: 0.7; }
|
||||
}
|
||||
|
||||
@keyframes drift {
|
||||
from { transform: scale(1.06) translate3d(0, 0, 0); }
|
||||
to { transform: scale(1.14) translate3d(-1.5%, -1.5%, 0); }
|
||||
}
|
||||
|
||||
@keyframes cue {
|
||||
0%, 100% { transform: translateY(0); opacity: 0.45; }
|
||||
50% { transform: translateY(6px); opacity: 1; }
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
/* Fixierter Header darf verlinkte Sektionen nicht ueberdecken */
|
||||
scroll-padding-top: 5rem;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-ink-950);
|
||||
color: --alpha(var(--color-white) / 90%);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: var(--color-gold-400);
|
||||
color: var(--color-ink-950);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-gold-400);
|
||||
outline-offset: 3px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@utility text-balance-pretty {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
/* Sanft einblenden, sobald ein Abschnitt in den Viewport scrollt.
|
||||
Ohne JS sichtbar - das Skript setzt data-reveal erst, wenn es laeuft. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
[data-reveal='pending'] {
|
||||
opacity: 0;
|
||||
transform: translateY(1.5rem);
|
||||
}
|
||||
|
||||
[data-reveal='shown'] {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition: opacity 0.7s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
transform 0.7s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||