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>
This commit is contained in:
2026-08-17 23:17:13 +02:00
co-authored by Claude Opus 5
commit 074c87ed79
38 changed files with 7740 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "djnizz-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--port", "4321"],
"port": 4321
}
]
}
+19
View File
@@ -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
+13
View File
@@ -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
+32
View File
@@ -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/
+57
View File
@@ -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
}
}
}
}
+38
View File
@@ -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"]
+163
View File
@@ -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.
+27
View File
@@ -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()],
},
});
+45
View File
@@ -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:
+5814
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://djnizz.at/sitemap-index.xml
+13
View File
@@ -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" }
]
}
+64
View File
@@ -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`);
}
+315
View File
@@ -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}`);
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

+108
View File
@@ -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>
+102
View File
@@ -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>
+112
View File
@@ -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>
+158
View File
@@ -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 &amp; 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&nbsp;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>
+66
View File
@@ -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>
+118
View File
@@ -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;
+106
View File
@@ -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>
+33
View File
@@ -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">&larr; 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>
+26
View File
@@ -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>
+76
View File
@@ -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}&nbsp;{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>
+59
View File
@@ -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}&nbsp;{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>
+16
View File
@@ -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>
+111
View File
@@ -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;
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}