feat: add website CMS module (06) and update TypeScript build info

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MBO-Tech-IT 2026-05-18 16:32:34 +02:00
parent a060751f45
commit db5e9bb5ce
32 changed files with 1937 additions and 1 deletions

View File

@ -0,0 +1,299 @@
# Modul: Website-CMS (Hero · Über uns · Galerie · Kontakt · Passwort)
> Vollständiges CMS für eine öffentliche Website: Hero-Sektion (Texte, Badges, Hintergrundbild, Logo, Favicon), Über-uns-Sektion (Texte, Statistiken, Sektionsbild), Bildergalerie (Upload, Sortierung, animiertes Grid), Kontakt-Daten (Info, Öffnungszeiten, Social Media) und Admin-Passwort-Änderung. Alle Sektionen lesen direkt aus Supabase; bei DB-Fehler greift ein statischer Fallback.
> **Anfrageformular** (Kontaktformular mit E-Mail-Versand) → Modul 07
---
## Enthaltene Dateien
| Ziel im neuen Projekt | Inhalt |
|---|---|
| `app/api/admin/hero/route.ts` | GET + PATCH: hero_content (UPSERT) + hero_badges (lesen) |
| `app/api/admin/hero/bild/route.ts` | POST/DELETE: Hintergrundbild (`hero-bilder` Bucket) |
| `app/api/admin/hero/logo/route.ts` | POST/DELETE: Logo (`site-assets` Bucket) |
| `app/api/admin/hero/favicon/route.ts` | POST/DELETE: Favicon (`site-assets` Bucket) |
| `app/api/admin/hero/badges/route.ts` | POST: Badge hinzufügen |
| `app/api/admin/hero/badges/id/route.ts` | PATCH/DELETE: Badge bearbeiten/löschen |
| `app/api/admin/galerie/route.ts` | GET/POST/PATCH/DELETE: Galerie-Bilder + Storage |
| `app/api/admin/galerie/sort/route.ts` | POST: Reihenfolge speichern |
| `app/api/admin/ueber-uns/route.ts` | GET + PATCH: ueber_uns_content (UPSERT) + Stats (lesen) |
| `app/api/admin/ueber-uns/upload/route.ts` | POST: Sektionsbild hochladen (`ueber-uns-bilder` Bucket) |
| `app/api/admin/ueber-uns/stats/route.ts` | POST: Statistik hinzufügen |
| `app/api/admin/ueber-uns/stats/id/route.ts` | PATCH/DELETE: Statistik bearbeiten/löschen |
| `app/api/admin/kontakt/route.ts` | GET + PATCH: kontakt_info (UPSERT) + Listen lesen |
| `app/api/admin/kontakt/oeffnungszeiten/route.ts` | POST: Öffnungszeit hinzufügen |
| `app/api/admin/kontakt/oeffnungszeiten/id/route.ts` | PATCH/DELETE |
| `app/api/admin/kontakt/social/route.ts` | POST: Social-Link hinzufügen |
| `app/api/admin/kontakt/social/id/route.ts` | PATCH/DELETE |
| `app/api/admin/passwort/route.ts` | POST: Admin-Passwort ändern (bcrypt) |
| `app/admin/hero/page.tsx` | Admin-Page Hero |
| `app/admin/galerie/page.tsx` | Admin-Page Galerie |
| `app/admin/ueber-uns/page.tsx` | Admin-Page Über uns |
| `app/admin/kontakt/page.tsx` | Admin-Page Kontakt |
| `app/admin/passwort/page.tsx` | Admin-Page Passwort |
| `components/GalerieAnzeige.tsx` | Öffentliche Galerie mit 3D-Flip-Animation |
| `components/admin/HeroVerwaltung.tsx` | Admin-UI: Hero |
| `components/admin/GalerieVerwaltung.tsx` | Admin-UI: Galerie |
| `components/admin/UeberUnsVerwaltung.tsx` | Admin-UI: Über uns |
| `components/admin/KontaktVerwaltung.tsx` | Admin-UI: Kontakt |
| `components/admin/PasswortAendern.tsx` | Admin-UI: Passwort ändern |
**Hinweis:** Ordner `id/` in `files/` entsprechen Next.js Dynamic-Route-Ordnern `[id]`.
---
## Voraussetzungen
Benötigt:
- `lib/supabase.ts` mit `createPublicClient()` und `createServiceClient()`
- `lib/admin-auth.ts` (Modul 02): `requireAdmin()` und `getAdminSession()`
- Tabelle `admins` mit Spalten `id`, `email`, `password_hash` (Modul 02)
```bash
npm install bcryptjs
npm install -D @types/bcryptjs
```
---
## Umgebungsvariablen
```env
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co # Für Storage-URL-Konstruktion im Client
SUPABASE_INTERNAL_URL=http://supabase-kong:8000 # Nur Docker-intern; API-Routen verwenden diese
```
---
## Supabase Storage Buckets anlegen
Im Supabase Dashboard → Storage → New Bucket für jeden Bucket:
| Bucket-Name | Public | Verwendung |
|---|---|---|
| `hero-bilder` | ✓ | Hero-Hintergrundbild |
| `site-assets` | ✓ | Logo + Favicon |
| `ueber-uns-bilder` | ✓ | Über-uns-Sektionsbild |
| `galerie-bilder` | ✓ | Galerie-Fotos |
---
## Datenbank-Migrationen (Supabase)
Die vollständige SQL-Datei liegt unter `migrations/MIGRATIONS_WEBSITE_CMS.sql`.
Im Supabase SQL-Editor einmalig ausführen. Sie enthält alle Tabellen, Spalten und RLS-Policies.
Erstellt werden:
- `hero_content` — Singleton (Seitenname, Tagline, Überschriften, Buttons, Bildpfade)
- `hero_badges` — Liste der Trust-Badges
- `ueber_uns_content` — Singleton (Eyebrow, Absätze, Bildpfad)
- `ueber_uns_stats` — Liste der Kennzahlen/Statistiken
- `galerie_bilder` — Liste der Galeriefotos (storage_path, alt_text, reihenfolge)
- `kontakt_info` — Singleton (Telefon, E-Mail, Adresse, Formular-Empfänger)
- `kontakt_oeffnungszeiten` — Liste der Öffnungszeiten
- `kontakt_social` — Liste der Social-Media-Links
Alle Tabellen erhalten `public read` RLS-Policy (anonymer Lesezugriff).
### Supabase-Typen (`lib/supabase.ts`) ergänzen
In `Database.public.Tables` hinzufügen:
```ts
hero_content: { Row: {
id: string; site_name: string; site_tagline: string;
eyebrow_text: string; headline1: string; headline2: string;
subtext1: string; subtext2: string;
cta1_text: string; cta1_href: string; cta2_text: string; cta2_href: string;
bg_image_path: string | null; logo_path: string | null; favicon_path: string | null;
updated_at: string;
} }
hero_badges: { Row: { id: string; text: string; reihenfolge: number } }
ueber_uns_content: { Row: {
id: string; eyebrow_text: string; absatz1: string; absatz2: string;
bild_url: string | null; updated_at: string;
} }
ueber_uns_stats: { Row: { id: string; wert: string; label: string; reihenfolge: number } }
galerie_bilder: { Row: {
id: string; storage_path: string; alt_text: string; reihenfolge: number;
} }
kontakt_info: { Row: {
id: string; telefon: string; email: string;
adresse_zeile1: string; adresse_zeile2: string;
formular_empfaenger: string; updated_at: string;
} }
kontakt_oeffnungszeiten: { Row: { id: string; tag: string; von: string; bis: string; reihenfolge: number } }
kontakt_social: { Row: { id: string; platform: string; url: string; reihenfolge: number } }
```
---
## Architektur-Muster
### UPSERT-Singleton (Einzel-Zeilen-Tabellen)
`hero_content`, `ueber_uns_content`, `kontakt_info` haben immer genau eine Zeile.
API-Route: erst `select('id').limit(1).single()` → bei Treffer `update`, sonst `insert`.
### CRUD-Liste (Tabellen mit `reihenfolge`)
`hero_badges`, `ueber_uns_stats`, `galerie_bilder`, `kontakt_oeffnungszeiten`, `kontakt_social` — klassisches INSERT/PATCH/DELETE. Reihenfolge wird als Integer gespeichert.
### Server Component + Fallback
Öffentliche Website-Sektionen sind async Server Components. Alle Supabase-Aufrufe in `try/catch`; bei Fehler werden hartcodierte Fallback-Daten gerendert.
### force-dynamic
Alle Admin-Pages müssen `export const dynamic = 'force-dynamic'` haben (verhindert statisches Pre-Rendering beim Docker-Build ohne Env-Variablen).
### Galerie: Hydration-Mismatch vermeiden
`GalerieAnzeige` initialisiert `slots` ohne Shuffle im `useState()`-Initializer (Server und Client rendern identisch). Der Shuffle + Intervall-Start läuft nur im `useEffect([])` nach dem Mount.
---
## Einbindung Schritt für Schritt
### 1. Dateien kopieren (Dynamic-Route-Ordner umbenennen)
```
files/app/api/admin/hero/badges/id/ → app/api/admin/hero/badges/[id]/
files/app/api/admin/ueber-uns/stats/id/ → app/api/admin/ueber-uns/stats/[id]/
files/app/api/admin/kontakt/oeffnungszeiten/id/ → app/api/admin/kontakt/oeffnungszeiten/[id]/
files/app/api/admin/kontakt/social/id/ → app/api/admin/kontakt/social/[id]/
```
### 2. Standardwerte anpassen
In `components/admin/HeroVerwaltung.tsx`:
```tsx
const DEFAULTS: HeroContent = {
site_name: 'Musterfirma', // ← eigenen Firmennamen setzen
site_tagline: '',
eyebrow_text: 'Ihr Slogan hier',
headline1: 'Wir lieben Qualität.',
headline2: 'Ihre Kunden auch.',
// ...
}
```
In `app/api/admin/hero/route.ts` PATCH-Handler dieselben Defaults setzen.
### 3. Fallback-Bild konfigurieren (`components/admin/HeroVerwaltung.tsx`)
```tsx
src={bgPreview ?? bgUrl ?? '/hero.jpg'}
```
Das Fallback-Bild `/hero.jpg` muss im `public/`-Ordner liegen (oder durch eigenen Pfad ersetzen).
### 4. Öffentliche Website-Sektionen einbinden
Die Galerie-Anzeige-Komponente auf der Hauptseite:
```tsx
// In app/page.tsx (async Server Component):
import { GalerieAnzeige } from '@/components/GalerieAnzeige'
const { data: galerie } = await supabase.from('galerie_bilder').select('*').order('reihenfolge')
const bilder = (galerie ?? []).map(b => ({
id: b.id,
src: `${supabaseUrl}/storage/v1/object/public/galerie-bilder/${b.storage_path}`,
alt: b.alt_text,
}))
<GalerieAnzeige bilder={bilder} />
```
### 5. Admin-Navigation ergänzen
```tsx
<a href="/admin/hero">Hero</a>
<a href="/admin/ueber-uns">Über uns</a>
<a href="/admin/galerie">Galerie</a>
<a href="/admin/kontakt">Kontakt</a>
<a href="/admin/passwort">Passwort</a>
```
---
## Anpassungspunkte
| Was | Wo |
|---|---|
| Firmenname / Standardtexte | `components/admin/HeroVerwaltung.tsx``DEFAULTS` + `api/admin/hero/route.ts` |
| Fallback-Hero-Bild | `components/admin/HeroVerwaltung.tsx``bgPreview ?? bgUrl ?? '/hero.jpg'` |
| Galerie-Flip-Geschwindigkeit | `components/GalerieAnzeige.tsx``INTERVAL_MS` (Standard: 1500 ms) |
| Galerie-Grid-Layout | `components/GalerieAnzeige.tsx``VISIBLE`, `ROW_H`, `numGroups`-Berechnung |
| Bucket-Namen | Alle `app/api/admin/*/route.ts``const BUCKET = '...'` |
| Max. Upload-Größe | `app/api/admin/galerie/route.ts``MAX_SIZE` |
---
## Integrations-Prompt
Kopiere diesen Prompt in eine neue KI-Konversation, nachdem du die `files/` in dein Projekt kopiert und die SQL-Migration ausgeführt hast. Ersetze alle `[PLATZHALTER]`.
```
Ich integriere das Website-CMS-Modul (Hero, Über uns, Galerie, Kontakt, Passwort) in mein Next.js/Supabase-Projekt.
PROJEKT-KONTEXT:
- Firmenname: [FIRMENNAME]
- Tagline (optional): [TAGLINE oder leer lassen]
- Hero-Eyebrow-Text: [z. B. "Ihr Ansprechpartner seit 2010"]
- Hero-Überschrift 1 (weiß): [z. B. "Wir lieben Qualität."]
- Hero-Überschrift 2 (akzentfarbe): [z. B. "Ihre Kunden auch."]
- Modul 02 (Admin-Auth) ist bereits integriert (requireAdmin + getAdminSession verfügbar)
- Tabelle `admins` mit password_hash-Spalte existiert bereits
BEREITS KOPIERTE DATEIEN (aus modules/06-website-cms/files/):
Wichtig: Ordner "id/" umbenennen zu "[id]" — betrifft:
- app/api/admin/hero/badges/id/ → [id]/
- app/api/admin/ueber-uns/stats/id/ → [id]/
- app/api/admin/kontakt/oeffnungszeiten/id/ → [id]/
- app/api/admin/kontakt/social/id/ → [id]/
AUFGABEN führe sie der Reihe nach aus:
1. SQL-MIGRATION: Führe modules/06-website-cms/migrations/MIGRATIONS_WEBSITE_CMS.sql
im Supabase SQL-Editor aus (enthält alle Tabellen + RLS-Policies).
2. SUPABASE STORAGE BUCKETS anlegen (alle Public):
- hero-bilder
- site-assets
- ueber-uns-bilder
- galerie-bilder
3. BCRYPTJS installieren (für Passwort-Änderung):
npm install bcryptjs && npm install -D @types/bcryptjs
4. SUPABASE-TYPEN: Lies lib/supabase.ts.
Ergänze in Database.public.Tables die Typen für:
hero_content, hero_badges, ueber_uns_content, ueber_uns_stats,
galerie_bilder, kontakt_info, kontakt_oeffnungszeiten, kontakt_social
(Typen aus modules/06-website-cms/TEMPLATE.md kopieren)
5. STANDARDWERTE anpassen:
a) Lies components/admin/HeroVerwaltung.tsx.
Setze in DEFAULTS: site_name: '[FIRMENNAME]', site_tagline: '[TAGLINE]',
eyebrow_text: '[EYEBROW]', headline1: '[H1]', headline2: '[H2]'
b) Lies app/api/admin/hero/route.ts.
Setze dieselben Werte als Fallback in den PATCH-Handler-fields.
6. FALLBACK-BILD: Lege eine Datei hero.jpg (oder eigenen Namen) in public/ ab.
Lies components/admin/HeroVerwaltung.tsx und ersetze '/hero.jpg' durch deinen Pfad.
7. ÖFFENTLICHE SEITE einbinden:
a) Lies app/page.tsx (oder deine Hauptseite).
Importiere und verwende die Sektionskomponenten (Hero, UeberUns, GalerieAnzeige).
Für GalerieAnzeige: Daten aus galerie_bilder laden, Storage-URLs konstruieren,
als `bilder: GalerieBild[]`-Array übergeben.
b) Kontaktsektion mit Formular → Modul 07 einbinden.
8. ADMIN-NAVIGATION ergänzen:
Lies app/admin/AdminNav.tsx (oder deine Admin-Nav).
Füge Links zu /admin/hero, /admin/ueber-uns, /admin/galerie, /admin/kontakt, /admin/passwort hinzu.
Import-Icons: Home, Info, Grid2x2, Phone, KeyRound (aus lucide-react).
9. TEST:
a) /admin/hero → Seitenname + Texte + Hintergrundbild + Logo + Favicon speichern
b) /admin/ueber-uns → Texte + Statistik hinzufügen
c) /admin/galerie → Bild hochladen, Reihenfolge ändern
d) /admin/kontakt → Öffnungszeiten + Social-Links anlegen, Formular-Empfänger eintragen
e) /admin/passwort → Passwort ändern (aktuelles Passwort + neues Passwort)
f) Öffentliche Hauptseite → alle Sektionen zeigen Daten aus der DB
Lies jede Datei vor dem Bearbeiten. Melde wenn alle Schritte abgeschlossen sind.
```

View File

@ -0,0 +1,16 @@
export const dynamic = 'force-dynamic'
import { GalerieVerwaltung } from '@/components/admin/GalerieVerwaltung'
import type { Metadata } from 'next'
export const metadata: Metadata = { title: 'Galerie' }
export default function AdminGaleriePage() {
return (
<div className="max-w-5xl mx-auto px-4 py-8">
<h1 className="text-2xl font-bold tracking-tight mb-6" style={{ color: 'var(--text-primary)' }}>
Galerie verwalten
</h1>
<GalerieVerwaltung />
</div>
)
}

View File

@ -0,0 +1,16 @@
export const dynamic = 'force-dynamic'
import { HeroVerwaltung } from '@/components/admin/HeroVerwaltung'
import type { Metadata } from 'next'
export const metadata: Metadata = { title: 'Hero' }
export default function AdminHeroPage() {
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-2xl font-bold tracking-tight mb-6" style={{ color: 'var(--text-primary)' }}>
Hero-Sektion
</h1>
<HeroVerwaltung />
</div>
)
}

View File

@ -0,0 +1,16 @@
export const dynamic = 'force-dynamic'
import { KontaktVerwaltung } from '@/components/admin/KontaktVerwaltung'
import type { Metadata } from 'next'
export const metadata: Metadata = { title: 'Kontakt' }
export default function AdminKontaktPage() {
return (
<div className="max-w-5xl mx-auto px-4 py-8">
<h1 className="text-2xl font-bold tracking-tight mb-6" style={{ color: 'var(--text-primary)' }}>
Kontaktdaten
</h1>
<KontaktVerwaltung />
</div>
)
}

View File

@ -0,0 +1,21 @@
export const dynamic = 'force-dynamic'
import { redirect } from 'next/navigation'
import { getAdminSession } from '@/lib/admin-auth'
import PasswortAendern from '@/components/admin/PasswortAendern'
export default async function PasswortPage() {
const session = await getAdminSession()
if (!session) redirect('/admin/login')
return (
<div style={{ padding: '32px', maxWidth: '600px' }}>
<h1 style={{ fontSize: '20px', fontWeight: 800, color: 'var(--text-primary)', marginBottom: '8px' }}>
Passwort ändern
</h1>
<p style={{ fontSize: '13px', color: 'var(--text-muted)', marginBottom: '28px' }}>
Angemeldet als <strong>{session.email}</strong>
</p>
<PasswortAendern />
</div>
)
}

View File

@ -0,0 +1,16 @@
export const dynamic = 'force-dynamic'
import { UeberUnsVerwaltung } from '@/components/admin/UeberUnsVerwaltung'
import type { Metadata } from 'next'
export const metadata: Metadata = { title: 'Über uns' }
export default function AdminUeberUnsPage() {
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-2xl font-bold tracking-tight mb-6" style={{ color: 'var(--text-primary)' }}>
Über uns
</h1>
<UeberUnsVerwaltung />
</div>
)
}

View File

@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/admin-auth'
import { createServiceClient } from '@/lib/supabase'
const BUCKET = 'galerie-bilder'
const ALLOWED = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
const MAX_SIZE = 10 * 1024 * 1024
export async function GET() {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const db = createServiceClient()
const { data, error } = await db.from('galerie_bilder').select('*').order('reihenfolge')
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
const base = (process.env.SUPABASE_INTERNAL_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL!).replace(/\/$/, '')
const bilder = (data ?? []).map(b => ({ ...b, url: `${base}/storage/v1/object/public/${BUCKET}/${b.storage_path}` }))
return NextResponse.json({ bilder })
}
export async function POST(req: NextRequest) {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const formData = await req.formData()
const file = formData.get('file') as File | null
const altText = (formData.get('alt_text') as string) ?? ''
if (!file) return NextResponse.json({ error: 'Keine Datei.' }, { status: 400 })
if (!ALLOWED.includes(file.type)) return NextResponse.json({ error: 'Nur JPG, PNG oder WebP.' }, { status: 400 })
if (file.size > MAX_SIZE) return NextResponse.json({ error: 'Maximal 10 MB.' }, { status: 400 })
const db = createServiceClient()
const ext = file.name.split('.').pop() ?? 'jpg'
const storagePath = `galerie/${Date.now()}.${ext}`
const { error: uploadErr } = await db.storage.from(BUCKET).upload(storagePath, await file.arrayBuffer(), { contentType: file.type, upsert: false })
if (uploadErr) return NextResponse.json({ error: uploadErr.message }, { status: 500 })
const { data: existing } = await db.from('galerie_bilder').select('reihenfolge').order('reihenfolge', { ascending: false }).limit(1)
const reihenfolge = (existing?.[0]?.reihenfolge ?? -1) + 1
const { data, error: dbErr } = await db.from('galerie_bilder').insert({ storage_path: storagePath, alt_text: altText, reihenfolge }).select().single()
if (dbErr) return NextResponse.json({ error: dbErr.message }, { status: 500 })
return NextResponse.json({ bild: data }, { status: 201 })
}
export async function PATCH(req: NextRequest) {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const { id, alt_text } = await req.json()
if (!id) return NextResponse.json({ error: 'id erforderlich' }, { status: 400 })
const db = createServiceClient()
const { error } = await db.from('galerie_bilder').update({ alt_text }).eq('id', id)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ success: true })
}
export async function DELETE(req: NextRequest) {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const { id, storagePath } = await req.json()
if (!id || !storagePath) return NextResponse.json({ error: 'id und storagePath erforderlich' }, { status: 400 })
const db = createServiceClient()
await db.storage.from(BUCKET).remove([storagePath])
await db.from('galerie_bilder').delete().eq('id', id)
return NextResponse.json({ success: true })
}

View File

@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/admin-auth'
import { createServiceClient } from '@/lib/supabase'
export async function POST(req: NextRequest) {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const { order } = await req.json() as { order: { id: string; reihenfolge: number }[] }
if (!Array.isArray(order)) return NextResponse.json({ error: 'order array erforderlich' }, { status: 400 })
const db = createServiceClient()
await Promise.all(order.map(({ id, reihenfolge }) =>
db.from('galerie_bilder').update({ reihenfolge }).eq('id', id)
))
return NextResponse.json({ success: true })
}

View File

@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/admin-auth'
import { createServiceClient } from '@/lib/supabase'
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const { id } = await params
const body = await req.json()
const db = createServiceClient()
const { error } = await db.from('hero_badges').update(body).eq('id', id)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ success: true })
}
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const { id } = await params
const db = createServiceClient()
const { error } = await db.from('hero_badges').delete().eq('id', id)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ success: true })
}

View File

@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/admin-auth'
import { createServiceClient } from '@/lib/supabase'
export async function POST(req: NextRequest) {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const { text, reihenfolge } = await req.json()
if (!text) return NextResponse.json({ error: 'Text erforderlich' }, { status: 400 })
const db = createServiceClient()
const { data, error } = await db
.from('hero_badges')
.insert({ text, reihenfolge: reihenfolge ?? 99 })
.select()
.single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ badge: data }, { status: 201 })
}

View File

@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/admin-auth'
import { createServiceClient } from '@/lib/supabase'
const BUCKET = 'hero-bilder'
export async function POST(req: NextRequest) {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const db = createServiceClient()
const formData = await req.formData()
const file = formData.get('file') as File
if (!file) return NextResponse.json({ error: 'file erforderlich' }, { status: 400 })
const ext = file.name.split('.').pop()?.toLowerCase() ?? 'jpg'
const path = `bg-${Date.now()}.${ext}`
const { data: existing } = await db.from('hero_content').select('bg_image_path').limit(1).single()
if (existing?.bg_image_path) {
await db.storage.from(BUCKET).remove([existing.bg_image_path])
}
const { error: uploadError } = await db.storage
.from(BUCKET)
.upload(path, file, { contentType: file.type, upsert: true })
if (uploadError) return NextResponse.json({ error: uploadError.message }, { status: 500 })
const { data: row } = await db.from('hero_content').select('id').limit(1).single()
const op = row
? db.from('hero_content').update({ bg_image_path: path }).eq('id', row.id)
: db.from('hero_content').insert({ bg_image_path: path })
const { error } = await op
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ path })
}
export async function DELETE() {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const db = createServiceClient()
const { data: existing } = await db.from('hero_content').select('id, bg_image_path').limit(1).single()
if (existing?.bg_image_path) {
await db.storage.from(BUCKET).remove([existing.bg_image_path])
}
if (existing?.id) {
await db.from('hero_content').update({ bg_image_path: null }).eq('id', existing.id)
}
return NextResponse.json({ success: true })
}

View File

@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/admin-auth'
import { createServiceClient } from '@/lib/supabase'
const BUCKET = 'site-assets'
export async function POST(req: NextRequest) {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const db = createServiceClient()
const formData = await req.formData()
const file = formData.get('file') as File
if (!file) return NextResponse.json({ error: 'file erforderlich' }, { status: 400 })
const ext = file.name.split('.').pop()?.toLowerCase() ?? 'png'
const path = `favicon-${Date.now()}.${ext}`
const { data: existing } = await db.from('hero_content').select('favicon_path').limit(1).single()
if (existing?.favicon_path) {
await db.storage.from(BUCKET).remove([existing.favicon_path])
}
const { error: uploadError } = await db.storage.from(BUCKET).upload(path, file, { contentType: file.type, upsert: true })
if (uploadError) return NextResponse.json({ error: uploadError.message }, { status: 500 })
const { data: row } = await db.from('hero_content').select('id').limit(1).single()
const { error } = row
? await db.from('hero_content').update({ favicon_path: path }).eq('id', row.id)
: await db.from('hero_content').insert({ favicon_path: path })
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ path })
}
export async function DELETE() {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const db = createServiceClient()
const { data: existing } = await db.from('hero_content').select('id, favicon_path').limit(1).single()
if (existing?.favicon_path) {
await db.storage.from(BUCKET).remove([existing.favicon_path])
}
if (existing?.id) {
await db.from('hero_content').update({ favicon_path: null }).eq('id', existing.id)
}
return NextResponse.json({ success: true })
}

View File

@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/admin-auth'
import { createServiceClient } from '@/lib/supabase'
const BUCKET = 'site-assets'
export async function POST(req: NextRequest) {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const db = createServiceClient()
const formData = await req.formData()
const file = formData.get('file') as File
if (!file) return NextResponse.json({ error: 'file erforderlich' }, { status: 400 })
const ext = file.name.split('.').pop()?.toLowerCase() ?? 'png'
const path = `logo-${Date.now()}.${ext}`
const { data: existing } = await db.from('hero_content').select('logo_path').limit(1).single()
if (existing?.logo_path) {
await db.storage.from(BUCKET).remove([existing.logo_path])
}
const { error: uploadError } = await db.storage.from(BUCKET).upload(path, file, { contentType: file.type, upsert: true })
if (uploadError) return NextResponse.json({ error: uploadError.message }, { status: 500 })
const { data: row } = await db.from('hero_content').select('id').limit(1).single()
const { error } = row
? await db.from('hero_content').update({ logo_path: path }).eq('id', row.id)
: await db.from('hero_content').insert({ logo_path: path })
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ path })
}
export async function DELETE() {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const db = createServiceClient()
const { data: existing } = await db.from('hero_content').select('id, logo_path').limit(1).single()
if (existing?.logo_path) {
await db.storage.from(BUCKET).remove([existing.logo_path])
}
if (existing?.id) {
await db.from('hero_content').update({ logo_path: null }).eq('id', existing.id)
}
return NextResponse.json({ success: true })
}

View File

@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/admin-auth'
import { createServiceClient } from '@/lib/supabase'
export async function GET() {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const db = createServiceClient()
const [{ data: content }, { data: badges }] = await Promise.all([
db.from('hero_content').select('*').limit(1).single(),
db.from('hero_badges').select('*').order('reihenfolge'),
])
return NextResponse.json({ content: content ?? null, badges: badges ?? [] })
}
export async function PATCH(req: NextRequest) {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const body = await req.json()
const db = createServiceClient()
const { data: existing } = await db.from('hero_content').select('id').limit(1).single()
const fields = {
site_name: body.site_name ?? 'Musterfirma',
site_tagline: body.site_tagline ?? '',
eyebrow_text: body.eyebrow_text ?? 'Ihr Slogan hier',
headline1: body.headline1 ?? 'Wir lieben Qualität.',
headline2: body.headline2 ?? 'Ihre Kunden auch.',
subtext1: body.subtext1 ?? '',
subtext2: body.subtext2 ?? '',
cta1_text: body.cta1_text ?? 'Jetzt anfragen',
cta1_href: body.cta1_href ?? '#kontakt',
cta2_text: body.cta2_text ?? 'Unsere Leistungen',
cta2_href: body.cta2_href ?? '#leistungen',
updated_at: new Date().toISOString(),
}
const { error } = existing
? await db.from('hero_content').update(fields).eq('id', existing.id)
: await db.from('hero_content').insert(fields)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ success: true })
}

View File

@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/admin-auth'
import { createServiceClient } from '@/lib/supabase'
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const { id } = await params
const body = await req.json()
const db = createServiceClient()
const { error } = await db.from('kontakt_oeffnungszeiten').update(body).eq('id', id)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ success: true })
}
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const { id } = await params
const db = createServiceClient()
const { error } = await db.from('kontakt_oeffnungszeiten').delete().eq('id', id)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ success: true })
}

View File

@ -0,0 +1,14 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/admin-auth'
import { createServiceClient } from '@/lib/supabase'
export async function POST(req: NextRequest) {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const { tag, von, bis, reihenfolge } = await req.json()
if (!tag || !von || !bis) return NextResponse.json({ error: 'tag, von und bis erforderlich' }, { status: 400 })
const db = createServiceClient()
const { data, error } = await db.from('kontakt_oeffnungszeiten').insert({ tag, von, bis, reihenfolge: reihenfolge ?? 99 }).select().single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ eintrag: data }, { status: 201 })
}

View File

@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/admin-auth'
import { createServiceClient } from '@/lib/supabase'
export async function GET() {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const db = createServiceClient()
const [{ data: info }, { data: zeiten }, { data: social }] = await Promise.all([
db.from('kontakt_info').select('*').limit(1).single(),
db.from('kontakt_oeffnungszeiten').select('*').order('reihenfolge'),
db.from('kontakt_social').select('*').order('reihenfolge'),
])
return NextResponse.json({ info: info ?? null, oeffnungszeiten: zeiten ?? [], social: social ?? [] })
}
export async function PATCH(req: NextRequest) {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const body = await req.json()
const db = createServiceClient()
const { data: existing } = await db.from('kontakt_info').select('id').limit(1).single()
const fields = {
telefon: body.telefon ?? '',
email: body.email ?? '',
adresse_zeile1: body.adresse_zeile1 ?? '',
adresse_zeile2: body.adresse_zeile2 ?? '',
formular_empfaenger: body.formular_empfaenger ?? '',
updated_at: new Date().toISOString(),
}
const { error } = existing
? await db.from('kontakt_info').update(fields).eq('id', existing.id)
: await db.from('kontakt_info').insert(fields)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ success: true })
}

View File

@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/admin-auth'
import { createServiceClient } from '@/lib/supabase'
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const { id } = await params
const body = await req.json()
const db = createServiceClient()
const { error } = await db.from('kontakt_social').update(body).eq('id', id)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ success: true })
}
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const { id } = await params
const db = createServiceClient()
const { error } = await db.from('kontakt_social').delete().eq('id', id)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ success: true })
}

View File

@ -0,0 +1,14 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/admin-auth'
import { createServiceClient } from '@/lib/supabase'
export async function POST(req: NextRequest) {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const { platform, url, reihenfolge } = await req.json()
if (!platform || !url) return NextResponse.json({ error: 'platform und url erforderlich' }, { status: 400 })
const db = createServiceClient()
const { data, error } = await db.from('kontakt_social').insert({ platform, url, reihenfolge: reihenfolge ?? 99 }).select().single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ eintrag: data }, { status: 201 })
}

View File

@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server'
import bcrypt from 'bcryptjs'
import { requireAdmin } from '@/lib/admin-auth'
import { createServiceClient } from '@/lib/supabase'
export async function POST(req: NextRequest) {
const session = await requireAdmin()
if (session instanceof NextResponse) return session
const { currentPassword, newPassword } = await req.json()
if (!currentPassword || !newPassword) {
return NextResponse.json({ error: 'Alle Felder erforderlich.' }, { status: 400 })
}
if (newPassword.length < 8) {
return NextResponse.json({ error: 'Neues Passwort muss mindestens 8 Zeichen haben.' }, { status: 400 })
}
const db = createServiceClient()
const { data: admin } = await db.from('admins').select('password_hash').eq('id', session.id).single()
if (!admin) return NextResponse.json({ error: 'Admin nicht gefunden.' }, { status: 404 })
const ok = await bcrypt.compare(currentPassword, admin.password_hash)
if (!ok) return NextResponse.json({ error: 'Aktuelles Passwort ist falsch.' }, { status: 401 })
const hash = await bcrypt.hash(newPassword, 10)
const { error } = await db.from('admins').update({ password_hash: hash }).eq('id', session.id)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ success: true })
}

View File

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/admin-auth'
import { createServiceClient } from '@/lib/supabase'
export async function GET() {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const db = createServiceClient()
const [{ data: content }, { data: stats }] = await Promise.all([
db.from('ueber_uns_content').select('*').limit(1).single(),
db.from('ueber_uns_stats').select('*').order('reihenfolge'),
])
return NextResponse.json({ content: content ?? null, stats: stats ?? [] })
}
export async function PATCH(req: NextRequest) {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const body = await req.json()
const db = createServiceClient()
const { data: existing } = await db.from('ueber_uns_content').select('id').limit(1).single()
const fields = {
eyebrow_text: body.eyebrow_text ?? 'Klein, aber fein',
absatz1: body.absatz1 ?? '',
absatz2: body.absatz2 ?? '',
bild_url: body.bild_url ?? null,
updated_at: new Date().toISOString(),
}
const { error } = existing
? await db.from('ueber_uns_content').update(fields).eq('id', existing.id)
: await db.from('ueber_uns_content').insert(fields)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ success: true })
}

View File

@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/admin-auth'
import { createServiceClient } from '@/lib/supabase'
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const { id } = await params
const body = await req.json()
const db = createServiceClient()
const { error } = await db.from('ueber_uns_stats').update(body).eq('id', id)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ success: true })
}
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const { id } = await params
const db = createServiceClient()
const { error } = await db.from('ueber_uns_stats').delete().eq('id', id)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ success: true })
}

View File

@ -0,0 +1,14 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/admin-auth'
import { createServiceClient } from '@/lib/supabase'
export async function POST(req: NextRequest) {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const { wert, label, reihenfolge } = await req.json()
if (!wert || !label) return NextResponse.json({ error: 'Wert und Label erforderlich' }, { status: 400 })
const db = createServiceClient()
const { data, error } = await db.from('ueber_uns_stats').insert({ wert, label, reihenfolge: reihenfolge ?? 99 }).select().single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ stat: data }, { status: 201 })
}

View File

@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/admin-auth'
import { createServiceClient } from '@/lib/supabase'
const BUCKET = 'ueber-uns-bilder'
const ALLOWED = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
const MAX_SIZE = 8 * 1024 * 1024
export async function POST(req: NextRequest) {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const formData = await req.formData()
const file = formData.get('file') as File | null
if (!file) return NextResponse.json({ error: 'Keine Datei.' }, { status: 400 })
if (!ALLOWED.includes(file.type)) return NextResponse.json({ error: 'Nur JPG, PNG oder WebP.' }, { status: 400 })
if (file.size > MAX_SIZE) return NextResponse.json({ error: 'Maximal 8 MB.' }, { status: 400 })
const db = createServiceClient()
const ext = file.name.split('.').pop() ?? 'jpg'
const path = `ueber-uns/${Date.now()}.${ext}`
const { error } = await db.storage.from(BUCKET).upload(path, await file.arrayBuffer(), { contentType: file.type, upsert: true })
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
const base = (process.env.SUPABASE_INTERNAL_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL!).replace(/\/$/, '')
return NextResponse.json({ url: `${base}/storage/v1/object/public/${BUCKET}/${path}` })
}

View File

@ -0,0 +1,155 @@
'use client'
import { useState, useEffect, useRef } from 'react'
export interface GalerieBild { id: string; src: string; alt: string }
interface Slot { bild: GalerieBild; phase: 'idle' | 'out' | 'in' }
const VISIBLE = 12
const INTERVAL_MS = 1500
const FLIP_MS = 360
const GAP = 8
const ROW_H = 165
function shuffle<T>(arr: T[]): T[] {
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]]
}
return a
}
export function GalerieAnzeige({ bilder }: { bilder: GalerieBild[] }) {
const n = Math.min(bilder.length, VISIBLE)
// Kein shuffle im initializer → Server und Client rendern identisch → kein Hydration-Mismatch
const [slots, setSlots] = useState<Slot[]>(() =>
bilder.slice(0, n).map(b => ({ bild: b, phase: 'idle' as const }))
)
const slotsRef = useRef<Slot[]>(slots)
useEffect(() => { slotsRef.current = slots }, [slots])
const flipping = useRef(new Set<number>())
const poolRef = useRef<GalerieBild[]>([])
const poolIdxRef = useRef(0)
useEffect(() => {
// Läuft nur auf dem Client, nach der Hydration
const shuffled = shuffle([...bilder])
poolRef.current = shuffled
poolIdxRef.current = n
setSlots(shuffled.slice(0, n).map(b => ({ bild: b, phase: 'idle' as const })))
if (bilder.length < 2) return
const id = setInterval(() => {
const cur = slotsRef.current
const free = cur.map((_, i) => i).filter(i => !flipping.current.has(i))
if (free.length === 0) return
const si = free[Math.floor(Math.random() * free.length)]
if (bilder.length > VISIBLE) {
const next = poolRef.current[poolIdxRef.current % poolRef.current.length]
poolIdxRef.current++
flip(si, next)
} else {
const others = free.filter(i => i !== si)
if (others.length === 0) return
const oi = others[Math.floor(Math.random() * others.length)]
flip(si, cur[oi].bild)
flip(oi, cur[si].bild)
}
}, INTERVAL_MS)
return () => clearInterval(id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) // bewusst leer: läuft genau einmal nach Mount
function flip(i: number, newBild: GalerieBild) {
flipping.current.add(i)
setSlots(p => p.map((s, j) => j === i ? { ...s, phase: 'out' } : s))
setTimeout(() => {
setSlots(p => p.map((s, j) => j === i ? { bild: newBild, phase: 'in' } : s))
setTimeout(() => {
setSlots(p => p.map((s, j) => j === i ? { ...s, phase: 'idle' } : s))
flipping.current.delete(i)
}, FLIP_MS)
}, FLIP_MS)
}
const imgStyle = (phase: string): React.CSSProperties => ({
position: 'absolute', inset: 0, width: '100%', height: '100%',
objectFit: 'cover', objectPosition: 'center',
animation:
phase === 'out' ? `galFlipOut ${FLIP_MS}ms ease-in forwards` :
phase === 'in' ? `galFlipIn ${FLIP_MS}ms ease-out forwards` :
'none',
})
const numGroups = Math.ceil(slots.length / 4)
return (
<>
<style>{`
@keyframes galFlipOut {
from { transform: perspective(700px) rotateY(0deg); }
to { transform: perspective(700px) rotateY(90deg); }
}
@keyframes galFlipIn {
from { transform: perspective(700px) rotateY(-90deg); }
to { transform: perspective(700px) rotateY(0deg); }
}
.galerie-cell:hover .galerie-hl { opacity: 1 !important; }
`}</style>
{/* Mobile */}
<div className="grid grid-cols-2 gap-2 md:hidden">
{slots.map((slot, si) => (
<div key={si} className="galerie-cell relative aspect-[4/3] overflow-hidden rounded-lg">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={slot.bild.src} alt={slot.bild.alt} style={imgStyle(slot.phase)} />
<div className="galerie-hl absolute inset-0 transition-opacity duration-300"
style={{ background: 'rgba(76,175,80,0.15)', opacity: 0 }} />
</div>
))}
</div>
{/* Desktop */}
<div className="hidden md:flex flex-col" style={{ gap: GAP }}>
{Array.from({ length: numGroups }, (_, gi) => {
const bigLeft = gi % 2 === 0
return (
<div key={gi} style={{
display: 'grid',
gridTemplateColumns: bigLeft ? '2fr 1fr' : '1fr 2fr',
gridTemplateRows: `repeat(3, ${ROW_H}px)`,
gap: GAP,
}}>
{[0, 1, 2, 3].map(idx => {
const si = gi * 4 + idx
const slot = slots[si]
if (!slot) return null
const cellStyle: React.CSSProperties = bigLeft
? { gridColumn: idx === 0 ? '1' : '2', gridRow: idx === 0 ? '1/4' : `${idx}` }
: { gridColumn: idx === 3 ? '2' : '1', gridRow: idx === 3 ? '1/4' : `${idx + 1}` }
return (
<div key={si} className="galerie-cell relative overflow-hidden rounded-lg" style={cellStyle}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={slot.bild.src} alt={slot.bild.alt} style={imgStyle(slot.phase)} />
<div className="galerie-hl absolute inset-0 transition-opacity duration-300"
style={{ background: 'rgba(76,175,80,0.15)', opacity: 0 }} />
</div>
)
})}
</div>
)
})}
</div>
</>
)
}

View File

@ -0,0 +1,86 @@
'use client'
import { useState, useEffect, useRef } from 'react'
interface GalerieBild { id: string; storage_path: string; alt_text: string; reihenfolge: number; url: string }
export function GalerieVerwaltung() {
const [bilder, setBilder] = useState<GalerieBild[]>([])
const [uploading, setUploading] = useState(false)
const [loading, setLoading] = useState(true)
const fileRef = useRef<HTMLInputElement>(null)
useEffect(() => {
fetch('/api/admin/galerie').then(r => r.json()).then(({ bilder: b }) => { setBilder(b ?? []); setLoading(false) })
}, [])
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? [])
if (!files.length) return
setUploading(true)
for (const file of files) {
const fd = new FormData(); fd.append('file', file); fd.append('alt_text', file.name.replace(/\.[^.]+$/, ''))
const res = await fetch('/api/admin/galerie', { method: 'POST', body: fd })
const { bild, error } = await res.json()
if (bild) {
const base = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
setBilder(prev => [...prev, { ...bild, url: `${base}/storage/v1/object/public/galerie-bilder/${bild.storage_path}` }])
} else { alert(error ?? 'Upload fehlgeschlagen') }
}
setUploading(false)
if (fileRef.current) fileRef.current.value = ''
}
async function handleDelete(bild: GalerieBild) {
if (!confirm(`"${bild.alt_text || bild.storage_path}" löschen?`)) return
await fetch('/api/admin/galerie', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: bild.id, storagePath: bild.storage_path }) })
setBilder(prev => prev.filter(b => b.id !== bild.id))
}
async function move(index: number, dir: -1 | 1) {
const newBilder = [...bilder]
const target = index + dir
if (target < 0 || target >= newBilder.length) return
;[newBilder[index], newBilder[target]] = [newBilder[target], newBilder[index]]
const withOrder = newBilder.map((b, i) => ({ ...b, reihenfolge: i }))
setBilder(withOrder)
await fetch('/api/admin/galerie/sort', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order: withOrder.map(b => ({ id: b.id, reihenfolge: b.reihenfolge })) }) })
}
if (loading) return <p style={{ color: 'var(--text-muted)' }}>Lade</p>
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '24px' }}>
<button onClick={() => fileRef.current?.click()} disabled={uploading} style={{ padding: '9px 18px', borderRadius: '6px', background: 'var(--accent)', color: '#fff', fontWeight: 700, border: 'none', cursor: 'pointer', fontSize: '13px' }}>
{uploading ? 'Hochladen…' : '+ Bilder hochladen'}
</button>
<input ref={fileRef} type="file" accept="image/*" multiple onChange={handleUpload} style={{ display: 'none' }} />
<span style={{ fontSize: '13px', color: 'var(--text-muted)' }}>{bilder.length} Bilder · Reihenfolge per </span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: '12px' }}>
{bilder.map((bild, i) => (
<div key={bild.id} style={{ background: 'var(--surface)', border: '1px solid var(--border-color)', borderRadius: '8px', overflow: 'hidden' }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={bild.url} alt={bild.alt_text} style={{ width: '100%', height: '120px', objectFit: 'cover', display: 'block' }} />
<div style={{ padding: '8px', display: 'flex', flexDirection: 'column', gap: '6px' }}>
<input
style={{ fontSize: '11px', padding: '4px 8px', borderRadius: '4px', border: '1px solid var(--border-color)', background: 'var(--bg)', color: 'var(--text-muted)', width: '100%', boxSizing: 'border-box' }}
defaultValue={bild.alt_text}
placeholder="Alt-Text"
onBlur={async e => { await fetch('/api/admin/galerie', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: bild.id, alt_text: e.target.value }) }) }}
/>
<div style={{ display: 'flex', gap: '4px', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', gap: '4px' }}>
<button onClick={() => move(i, -1)} disabled={i === 0} style={{ padding: '3px 7px', borderRadius: '4px', border: '1px solid var(--border-color)', background: 'transparent', color: 'var(--text-muted)', cursor: 'pointer', fontSize: '12px' }}></button>
<button onClick={() => move(i, 1)} disabled={i === bilder.length - 1} style={{ padding: '3px 7px', borderRadius: '4px', border: '1px solid var(--border-color)', background: 'transparent', color: 'var(--text-muted)', cursor: 'pointer', fontSize: '12px' }}></button>
</div>
<button onClick={() => handleDelete(bild)} style={{ padding: '3px 7px', borderRadius: '4px', border: '1px solid rgba(220,38,38,0.3)', background: 'transparent', color: '#f87171', cursor: 'pointer', fontSize: '12px' }}></button>
</div>
</div>
</div>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,339 @@
'use client'
import { useState, useEffect, useRef } from 'react'
interface HeroContent {
site_name: string
site_tagline: string
eyebrow_text: string
headline1: string
headline2: string
subtext1: string
subtext2: string
cta1_text: string; cta1_href: string
cta2_text: string; cta2_href: string
bg_image_path: string | null
}
interface Badge { id: string; text: string; reihenfolge: number }
const inp: React.CSSProperties = {
width: '100%', padding: '8px 12px', borderRadius: '6px',
border: '1px solid var(--border-color)', background: 'var(--bg)',
color: 'var(--text-primary)', fontSize: '14px', boxSizing: 'border-box',
}
const lbl: React.CSSProperties = {
fontSize: '11px', fontWeight: 600, textTransform: 'uppercase',
letterSpacing: '1px', color: 'var(--text-muted)', display: 'block', marginBottom: '6px',
}
const section: React.CSSProperties = {
background: 'var(--surface)', border: '1px solid var(--border-color)',
borderLeft: '3px solid var(--accent)', borderRadius: '8px', padding: '20px',
}
// ← Standardwerte an eigenes Projekt anpassen
const DEFAULTS: HeroContent = {
site_name: 'Musterfirma',
site_tagline: '',
eyebrow_text: 'Ihr Slogan hier',
headline1: 'Wir lieben Qualität.',
headline2: 'Ihre Kunden auch.',
subtext1: '', subtext2: '',
cta1_text: 'Jetzt anfragen', cta1_href: '#kontakt',
cta2_text: 'Unsere Leistungen', cta2_href: '#leistungen',
bg_image_path: null,
}
export function HeroVerwaltung() {
const [content, setContent] = useState<HeroContent>(DEFAULTS)
const [badges, setBadges] = useState<Badge[]>([])
const [newBadge, setNewBadge] = useState('')
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [loading, setLoading] = useState(true)
const [uploading, setUploading] = useState(false)
const [bgPreview, setBgPreview] = useState<string | null>(null)
const fileRef = useRef<HTMLInputElement>(null)
const logoRef = useRef<HTMLInputElement>(null)
const faviconRef = useRef<HTMLInputElement>(null)
const [logoPath, setLogoPath] = useState<string | null>(null)
const [faviconPath, setFaviconPath] = useState<string | null>(null)
const [uploadingLogo, setUploadingLogo] = useState(false)
const [uploadingFavicon, setUploadingFavicon] = useState(false)
const supabaseUrl = (process.env.NEXT_PUBLIC_SUPABASE_URL ?? '').replace(/\/$/, '')
useEffect(() => {
fetch('/api/admin/hero').then(r => r.json()).then(({ content: c, badges: b }) => {
if (c) {
setContent({ ...DEFAULTS, ...c })
if (c.logo_path) setLogoPath(c.logo_path)
if (c.favicon_path) setFaviconPath(c.favicon_path)
}
setBadges(b ?? [])
setLoading(false)
})
}, [])
const bgUrl = content.bg_image_path
? `${supabaseUrl}/storage/v1/object/public/hero-bilder/${content.bg_image_path}`
: null
async function handleSave(e: React.FormEvent) {
e.preventDefault()
setSaving(true)
await fetch('/api/admin/hero', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(content),
})
setSaving(false); setSaved(true); setTimeout(() => setSaved(false), 2000)
}
async function uploadBg(file: File) {
setUploading(true)
setBgPreview(URL.createObjectURL(file))
const fd = new FormData()
fd.append('file', file)
const res = await fetch('/api/admin/hero/bild', { method: 'POST', body: fd })
const json = await res.json()
if (res.ok) setContent(p => ({ ...p, bg_image_path: json.path }))
setUploading(false)
if (fileRef.current) fileRef.current.value = ''
}
async function deleteBg() {
await fetch('/api/admin/hero/bild', { method: 'DELETE' })
setContent(p => ({ ...p, bg_image_path: null }))
setBgPreview(null)
}
async function uploadLogo(file: File) {
setUploadingLogo(true)
const fd = new FormData(); fd.append('file', file)
const res = await fetch('/api/admin/hero/logo', { method: 'POST', body: fd })
const json = await res.json()
if (res.ok) setLogoPath(json.path)
setUploadingLogo(false)
if (logoRef.current) logoRef.current.value = ''
}
async function deleteLogo() {
await fetch('/api/admin/hero/logo', { method: 'DELETE' })
setLogoPath(null)
}
async function uploadFavicon(file: File) {
setUploadingFavicon(true)
const fd = new FormData(); fd.append('file', file)
const res = await fetch('/api/admin/hero/favicon', { method: 'POST', body: fd })
const json = await res.json()
if (res.ok) setFaviconPath(json.path)
setUploadingFavicon(false)
if (faviconRef.current) faviconRef.current.value = ''
}
async function deleteFavicon() {
await fetch('/api/admin/hero/favicon', { method: 'DELETE' })
setFaviconPath(null)
}
async function addBadge() {
if (!newBadge.trim()) return
const res = await fetch('/api/admin/hero/badges', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: newBadge.trim(), reihenfolge: badges.length }),
})
const { badge } = await res.json()
setBadges(prev => [...prev, badge]); setNewBadge('')
}
async function deleteBadge(id: string) {
await fetch(`/api/admin/hero/badges/${id}`, { method: 'DELETE' })
setBadges(prev => prev.filter(b => b.id !== id))
}
if (loading) return <p style={{ color: 'var(--text-muted)' }}>Lade</p>
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
<form onSubmit={handleSave} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px', alignItems: 'start' }}>
{/* Linke Spalte: Texte */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div style={section}>
<p style={{ fontSize: '13px', fontWeight: 700, color: 'var(--text-primary)', marginBottom: '16px' }}>Navbar (Logo)</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div>
<label style={lbl}>Seitenname (Titel)</label>
<input style={inp} value={content.site_name} onChange={e => setContent(p => ({ ...p, site_name: e.target.value }))} placeholder="z. B. Musterfirma" />
</div>
<div>
<label style={lbl}>Tagline (Untertitel)</label>
<input style={inp} value={content.site_tagline} onChange={e => setContent(p => ({ ...p, site_tagline: e.target.value }))} placeholder="z. B. Ihr Dienstleister aus der Region" />
</div>
</div>
</div>
<div style={section}>
<p style={{ fontSize: '13px', fontWeight: 700, color: 'var(--text-primary)', marginBottom: '16px' }}>Überschriften</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div>
<label style={lbl}>Eyebrow-Text</label>
<input style={inp} value={content.eyebrow_text} onChange={e => setContent(p => ({ ...p, eyebrow_text: e.target.value }))} placeholder="z. B. Ihr Slogan hier" />
</div>
<div>
<label style={lbl}>Überschrift Zeile 1 (weiß)</label>
<input style={inp} value={content.headline1} onChange={e => setContent(p => ({ ...p, headline1: e.target.value }))} placeholder="z. B. Wir lieben Qualität." />
</div>
<div>
<label style={lbl}>Überschrift Zeile 2 (Akzentfarbe)</label>
<input style={inp} value={content.headline2} onChange={e => setContent(p => ({ ...p, headline2: e.target.value }))} placeholder="z. B. Ihre Kunden auch." />
</div>
</div>
</div>
<div style={section}>
<p style={{ fontSize: '13px', fontWeight: 700, color: 'var(--text-primary)', marginBottom: '16px' }}>Fließtexte</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div>
<label style={lbl}>Subtext 1 (groß)</label>
<textarea rows={3} style={{ ...inp, resize: 'vertical' }} value={content.subtext1} onChange={e => setContent(p => ({ ...p, subtext1: e.target.value }))} />
</div>
<div>
<label style={lbl}>Subtext 2 (klein)</label>
<textarea rows={3} style={{ ...inp, resize: 'vertical' }} value={content.subtext2} onChange={e => setContent(p => ({ ...p, subtext2: e.target.value }))} />
</div>
</div>
</div>
<div style={section}>
<p style={{ fontSize: '13px', fontWeight: 700, color: 'var(--text-primary)', marginBottom: '16px' }}>Buttons</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div>
<label style={lbl}>Button 1 (primär)</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
<input style={inp} value={content.cta1_text} onChange={e => setContent(p => ({ ...p, cta1_text: e.target.value }))} placeholder="Jetzt anfragen" />
<input style={inp} value={content.cta1_href} onChange={e => setContent(p => ({ ...p, cta1_href: e.target.value }))} placeholder="#kontakt" />
</div>
</div>
<div>
<label style={lbl}>Button 2 (sekundär)</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
<input style={inp} value={content.cta2_text} onChange={e => setContent(p => ({ ...p, cta2_text: e.target.value }))} placeholder="Unsere Leistungen" />
<input style={inp} value={content.cta2_href} onChange={e => setContent(p => ({ ...p, cta2_href: e.target.value }))} placeholder="#leistungen" />
</div>
</div>
</div>
</div>
<button type="submit" disabled={saving} style={{ padding: '10px', borderRadius: '6px', background: 'var(--accent)', color: '#fff', fontWeight: 700, border: 'none', cursor: 'pointer', fontSize: '14px' }}>
{saving ? 'Speichert…' : saved ? '✓ Gespeichert' : 'Alles speichern'}
</button>
</div>
{/* Rechte Spalte: Bilder + Badges */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{/* Logo */}
<div style={section}>
<p style={{ fontSize: '13px', fontWeight: 700, color: 'var(--text-primary)', marginBottom: '16px' }}>Logo</p>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px', padding: '12px', borderRadius: '6px', background: 'var(--bg)', border: '1px solid var(--border-color)', minHeight: '56px' }}>
{logoPath
? /* eslint-disable-next-line @next/next/no-img-element */
<img src={`${supabaseUrl}/storage/v1/object/public/site-assets/${logoPath}`} alt="Logo" style={{ height: '40px', width: 'auto', objectFit: 'contain' }} />
: <span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>Kein Logo Textname wird angezeigt</span>
}
{logoPath && (
<button type="button" onClick={deleteLogo} style={{ marginLeft: 'auto', padding: '4px 10px', borderRadius: '4px', background: 'transparent', color: '#f87171', border: '1px solid rgba(220,38,38,0.3)', cursor: 'pointer', fontSize: '12px' }}> Entfernen</button>
)}
</div>
<input ref={logoRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={e => { const f = e.target.files?.[0]; if (f) uploadLogo(f) }} />
<button type="button" onClick={() => logoRef.current?.click()} disabled={uploadingLogo} style={{ width: '100%', padding: '8px', borderRadius: '6px', background: 'transparent', border: '1px dashed var(--border-color)', color: 'var(--text-muted)', cursor: 'pointer', fontSize: '13px' }}>
{uploadingLogo ? 'Wird hochgeladen…' : '+ Logo hochladen (PNG/SVG empfohlen)'}
</button>
</div>
{/* Favicon */}
<div style={section}>
<p style={{ fontSize: '13px', fontWeight: 700, color: 'var(--text-primary)', marginBottom: '16px' }}>Favicon (Browser-Tab-Icon)</p>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px', padding: '12px', borderRadius: '6px', background: 'var(--bg)', border: '1px solid var(--border-color)', minHeight: '56px' }}>
{faviconPath
? /* eslint-disable-next-line @next/next/no-img-element */
<img src={`${supabaseUrl}/storage/v1/object/public/site-assets/${faviconPath}`} alt="Favicon" style={{ width: '32px', height: '32px', objectFit: 'contain' }} />
: <span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>Kein Favicon Standard-Icon wird verwendet</span>
}
{faviconPath && (
<button type="button" onClick={deleteFavicon} style={{ marginLeft: 'auto', padding: '4px 10px', borderRadius: '4px', background: 'transparent', color: '#f87171', border: '1px solid rgba(220,38,38,0.3)', cursor: 'pointer', fontSize: '12px' }}> Entfernen</button>
)}
</div>
<input ref={faviconRef} type="file" accept="image/*, .ico" style={{ display: 'none' }} onChange={e => { const f = e.target.files?.[0]; if (f) uploadFavicon(f) }} />
<button type="button" onClick={() => faviconRef.current?.click()} disabled={uploadingFavicon} style={{ width: '100%', padding: '8px', borderRadius: '6px', background: 'transparent', border: '1px dashed var(--border-color)', color: 'var(--text-muted)', cursor: 'pointer', fontSize: '13px' }}>
{uploadingFavicon ? 'Wird hochgeladen…' : '+ Favicon hochladen (.ico / PNG / SVG)'}
</button>
</div>
{/* Hintergrundbild */}
<div style={section}>
<p style={{ fontSize: '13px', fontWeight: 700, color: 'var(--text-primary)', marginBottom: '16px' }}>Hintergrundbild</p>
<div style={{ position: 'relative', width: '100%', aspectRatio: '16/7', borderRadius: '6px', overflow: 'hidden', marginBottom: '12px', background: 'var(--bg)', border: '1px solid var(--border-color)' }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={bgPreview ?? bgUrl ?? '/hero.jpg'}
alt="Hero Hintergrund"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
{content.bg_image_path && (
<button
type="button"
onClick={deleteBg}
style={{ position: 'absolute', top: '8px', right: '8px', padding: '4px 10px', borderRadius: '4px', background: 'rgba(0,0,0,0.6)', color: '#f87171', border: 'none', cursor: 'pointer', fontSize: '12px', fontWeight: 600 }}
>
Zurücksetzen
</button>
)}
</div>
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={e => { const f = e.target.files?.[0]; if (f) uploadBg(f) }} />
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={uploading}
style={{ width: '100%', padding: '8px', borderRadius: '6px', background: 'transparent', border: '1px dashed var(--border-color)', color: 'var(--text-muted)', cursor: 'pointer', fontSize: '13px' }}
>
{uploading ? 'Wird hochgeladen…' : '+ Bild hochladen oder ersetzen'}
</button>
{!content.bg_image_path && (
<p style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '6px' }}>
Kein Bild gesetzt Standard: /hero.jpg
</p>
)}
</div>
{/* Trust-Badges */}
<div style={section}>
<p style={{ fontSize: '13px', fontWeight: 700, color: 'var(--text-primary)', marginBottom: '16px' }}>Trust-Badges</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '12px' }}>
{badges.map(b => (
<div key={b.id} style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<span style={{ flex: 1, padding: '8px 12px', borderRadius: '6px', background: 'var(--bg)', border: '1px solid var(--border-color)', fontSize: '13px', color: 'var(--text-primary)' }}>
{b.text}
</span>
<button type="button" onClick={() => deleteBadge(b.id)} style={{ padding: '6px 10px', borderRadius: '6px', background: 'transparent', border: '1px solid rgba(220,38,38,0.3)', color: '#f87171', cursor: 'pointer', fontSize: '12px' }}></button>
</div>
))}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<input
style={{ ...inp, flex: 1 }}
value={newBadge}
onChange={e => setNewBadge(e.target.value)}
placeholder="z. B. Privatkunden"
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addBadge())}
/>
<button type="button" onClick={addBadge} style={{ padding: '8px 14px', borderRadius: '6px', background: 'var(--accent)', color: '#fff', fontWeight: 700, border: 'none', cursor: 'pointer', fontSize: '13px' }}>+</button>
</div>
</div>
</div>
</form>
</div>
)
}

View File

@ -0,0 +1,109 @@
'use client'
import { useState, useEffect } from 'react'
interface KontaktInfo { telefon: string; email: string; adresse_zeile1: string; adresse_zeile2: string; formular_empfaenger: string }
interface Oeffnungszeit { id: string; tag: string; von: string; bis: string; reihenfolge: number }
interface SocialLink { id: string; platform: string; url: string; reihenfolge: number }
const inp: React.CSSProperties = { width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--border-color)', background: 'var(--bg)', color: 'var(--text-primary)', fontSize: '14px', boxSizing: 'border-box' }
const lbl: React.CSSProperties = { fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '1px', color: 'var(--text-muted)', display: 'block', marginBottom: '6px' }
export function KontaktVerwaltung() {
const [info, setInfo] = useState<KontaktInfo>({ telefon: '', email: '', adresse_zeile1: '', adresse_zeile2: '', formular_empfaenger: '' })
const [zeiten, setZeiten] = useState<Oeffnungszeit[]>([])
const [social, setSocial] = useState<SocialLink[]>([])
const [newZeit, setNewZeit] = useState({ tag: '', von: '', bis: '' })
const [newSocial, setNewSocial] = useState({ platform: '', url: '' })
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/admin/kontakt').then(r => r.json()).then(({ info: i, oeffnungszeiten: z, social: s }) => {
if (i) setInfo(i)
setZeiten(z ?? []); setSocial(s ?? []); setLoading(false)
})
}, [])
async function handleSave(e: React.FormEvent) {
e.preventDefault(); setSaving(true)
await fetch('/api/admin/kontakt', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(info) })
setSaving(false); setSaved(true); setTimeout(() => setSaved(false), 2000)
}
async function addZeit() {
if (!newZeit.tag || !newZeit.von || !newZeit.bis) return
const res = await fetch('/api/admin/kontakt/oeffnungszeiten', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...newZeit, reihenfolge: zeiten.length }) })
const { eintrag } = await res.json()
setZeiten(prev => [...prev, eintrag]); setNewZeit({ tag: '', von: '', bis: '' })
}
async function deleteZeit(id: string) {
await fetch(`/api/admin/kontakt/oeffnungszeiten/${id}`, { method: 'DELETE' })
setZeiten(prev => prev.filter(z => z.id !== id))
}
async function addSocial() {
if (!newSocial.platform || !newSocial.url) return
const res = await fetch('/api/admin/kontakt/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...newSocial, reihenfolge: social.length }) })
const { eintrag } = await res.json()
setSocial(prev => [...prev, eintrag]); setNewSocial({ platform: '', url: '' })
}
async function deleteSocial(id: string) {
await fetch(`/api/admin/kontakt/social/${id}`, { method: 'DELETE' })
setSocial(prev => prev.filter(s => s.id !== id))
}
if (loading) return <p style={{ color: 'var(--text-muted)' }}>Lade</p>
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '32px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
<form onSubmit={handleSave} style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<label style={lbl}>Kontaktinfos</label>
{([['telefon', 'Telefon'], ['email', 'E-Mail (Anzeige)'], ['adresse_zeile1', 'Adresse Zeile 1'], ['adresse_zeile2', 'Adresse Zeile 2'], ['formular_empfaenger', 'Formular-Empfänger (E-Mail)']] as [keyof KontaktInfo, string][]).map(([key, placeholder]) => (
<input key={key} style={inp} value={info[key]} onChange={e => setInfo(p => ({ ...p, [key]: e.target.value }))} placeholder={placeholder} />
))}
<button type="submit" disabled={saving} style={{ padding: '10px', borderRadius: '6px', background: 'var(--accent)', color: '#fff', fontWeight: 700, border: 'none', cursor: 'pointer', fontSize: '14px' }}>
{saving ? 'Speichert…' : saved ? '✓ Gespeichert' : 'Speichern'}
</button>
</form>
<div>
<label style={lbl}>Social Media</label>
{social.map(s => (
<div key={s.id} style={{ display: 'grid', gridTemplateColumns: '80px 1fr auto', gap: '8px', marginBottom: '6px', alignItems: 'center' }}>
<span style={{ fontSize: '13px', color: 'var(--text-muted)' }}>{s.platform}</span>
<input style={inp} defaultValue={s.url} onBlur={async e => { await fetch(`/api/admin/kontakt/social/${s.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: e.target.value }) }) }} />
<button onClick={() => deleteSocial(s.id)} style={{ padding: '6px 10px', borderRadius: '6px', background: 'transparent', border: '1px solid rgba(220,38,38,0.3)', color: '#f87171', cursor: 'pointer', fontSize: '12px' }}></button>
</div>
))}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr auto', gap: '8px', marginTop: '8px' }}>
<input style={inp} value={newSocial.platform} onChange={e => setNewSocial(p => ({ ...p, platform: e.target.value }))} placeholder="Instagram" />
<input style={inp} value={newSocial.url} onChange={e => setNewSocial(p => ({ ...p, url: e.target.value }))} placeholder="https://…" />
<button type="button" onClick={addSocial} style={{ padding: '8px 14px', borderRadius: '6px', background: 'var(--accent)', color: '#fff', fontWeight: 700, border: 'none', cursor: 'pointer', fontSize: '13px' }}>+</button>
</div>
</div>
</div>
<div>
<label style={lbl}>Öffnungszeiten</label>
{zeiten.map(z => (
<div key={z.id} style={{ display: 'grid', gridTemplateColumns: '1fr 70px 70px auto', gap: '8px', marginBottom: '6px', alignItems: 'center' }}>
<input style={inp} defaultValue={z.tag} onBlur={async e => { await fetch(`/api/admin/kontakt/oeffnungszeiten/${z.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tag: e.target.value }) }) }} />
<input style={inp} defaultValue={z.von} onBlur={async e => { await fetch(`/api/admin/kontakt/oeffnungszeiten/${z.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ von: e.target.value }) }) }} placeholder="8:00" />
<input style={inp} defaultValue={z.bis} onBlur={async e => { await fetch(`/api/admin/kontakt/oeffnungszeiten/${z.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bis: e.target.value }) }) }} placeholder="18:00" />
<button onClick={() => deleteZeit(z.id)} style={{ padding: '6px 10px', borderRadius: '6px', background: 'transparent', border: '1px solid rgba(220,38,38,0.3)', color: '#f87171', cursor: 'pointer', fontSize: '12px' }}></button>
</div>
))}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 70px 70px auto', gap: '8px', marginTop: '8px' }}>
<input style={inp} value={newZeit.tag} onChange={e => setNewZeit(p => ({ ...p, tag: e.target.value }))} placeholder="Montag Freitag" />
<input style={inp} value={newZeit.von} onChange={e => setNewZeit(p => ({ ...p, von: e.target.value }))} placeholder="8:00" />
<input style={inp} value={newZeit.bis} onChange={e => setNewZeit(p => ({ ...p, bis: e.target.value }))} placeholder="18:00" />
<button type="button" onClick={addZeit} style={{ padding: '8px 14px', borderRadius: '6px', background: 'var(--accent)', color: '#fff', fontWeight: 700, border: 'none', cursor: 'pointer', fontSize: '13px' }}>+</button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,73 @@
'use client'
import { useState } from 'react'
const inp: React.CSSProperties = {
width: '100%', padding: '8px 12px', borderRadius: '6px',
border: '1px solid var(--border-color)', background: 'var(--bg)',
color: 'var(--text-primary)', fontSize: '14px', boxSizing: 'border-box',
}
const lbl: React.CSSProperties = {
fontSize: '11px', fontWeight: 600, textTransform: 'uppercase',
letterSpacing: '1px', color: 'var(--text-muted)', display: 'block', marginBottom: '6px',
}
export default function PasswortAendern() {
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [confirm, setConfirm] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
if (next !== confirm) { setError('Die neuen Passwörter stimmen nicht überein.'); return }
if (next.length < 8) { setError('Das neue Passwort muss mindestens 8 Zeichen haben.'); return }
setSaving(true)
const res = await fetch('/api/admin/passwort', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword: current, newPassword: next }),
})
const json = await res.json()
setSaving(false)
if (!res.ok) { setError(json.error ?? 'Fehler beim Speichern.'); return }
setSuccess(true)
setCurrent(''); setNext(''); setConfirm('')
setTimeout(() => setSuccess(false), 3000)
}
return (
<div style={{ maxWidth: '480px' }}>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div>
<label style={lbl}>Aktuelles Passwort</label>
<input type="password" style={inp} value={current} onChange={e => setCurrent(e.target.value)} autoComplete="current-password" required />
</div>
<div>
<label style={lbl}>Neues Passwort</label>
<input type="password" style={inp} value={next} onChange={e => setNext(e.target.value)} autoComplete="new-password" required />
</div>
<div>
<label style={lbl}>Neues Passwort bestätigen</label>
<input type="password" style={inp} value={confirm} onChange={e => setConfirm(e.target.value)} autoComplete="new-password" required />
</div>
{error && (
<p style={{ fontSize: '13px', color: '#f87171', padding: '10px 14px', borderRadius: '6px', background: 'rgba(248,113,113,0.1)', border: '1px solid rgba(248,113,113,0.3)' }}>
{error}
</p>
)}
<button
type="submit"
disabled={saving}
style={{ padding: '10px', borderRadius: '6px', background: 'var(--accent)', color: '#fff', fontWeight: 700, border: 'none', cursor: 'pointer', fontSize: '14px' }}
>
{saving ? 'Wird gespeichert…' : success ? '✓ Passwort geändert' : 'Passwort ändern'}
</button>
</form>
</div>
)
}

View File

@ -0,0 +1,109 @@
'use client'
import { useState, useEffect } from 'react'
interface Stat { id: string; wert: string; label: string; reihenfolge: number }
const inp: React.CSSProperties = { width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--border-color)', background: 'var(--bg)', color: 'var(--text-primary)', fontSize: '14px', boxSizing: 'border-box' }
const lbl: React.CSSProperties = { fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '1px', color: 'var(--text-muted)', display: 'block', marginBottom: '6px' }
export function UeberUnsVerwaltung() {
const [eyebrowText, setEyebrowText] = useState('Klein, aber fein')
const [absatz1, setAbsatz1] = useState('')
const [absatz2, setAbsatz2] = useState('')
const [bildUrl, setBildUrl] = useState<string | null>(null)
const [stats, setStats] = useState<Stat[]>([])
const [newStat, setNewStat] = useState({ wert: '', label: '' })
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [uploading, setUploading] = useState(false)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/admin/ueber-uns').then(r => r.json()).then(({ content: c, stats: s }) => {
if (c) { setEyebrowText(c.eyebrow_text ?? 'Klein, aber fein'); setAbsatz1(c.absatz1 ?? ''); setAbsatz2(c.absatz2 ?? ''); setBildUrl(c.bild_url ?? null) }
setStats(s ?? [])
setLoading(false)
})
}, [])
async function handleSave(e: React.FormEvent) {
e.preventDefault(); setSaving(true)
await fetch('/api/admin/ueber-uns', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ eyebrow_text: eyebrowText, absatz1, absatz2, bild_url: bildUrl }) })
setSaving(false); setSaved(true); setTimeout(() => setSaved(false), 2000)
}
async function handleBildUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
const fd = new FormData(); fd.append('file', file)
const res = await fetch('/api/admin/ueber-uns/upload', { method: 'POST', body: fd })
const { url, error } = await res.json()
if (url) setBildUrl(url)
else alert(error ?? 'Upload fehlgeschlagen')
setUploading(false)
}
async function addStat() {
if (!newStat.wert.trim() || !newStat.label.trim()) return
const res = await fetch('/api/admin/ueber-uns/stats', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...newStat, reihenfolge: stats.length }) })
const { stat } = await res.json()
setStats(prev => [...prev, stat]); setNewStat({ wert: '', label: '' })
}
async function deleteStat(id: string) {
await fetch(`/api/admin/ueber-uns/stats/${id}`, { method: 'DELETE' })
setStats(prev => prev.filter(s => s.id !== id))
}
if (loading) return <p style={{ color: 'var(--text-muted)' }}>Lade</p>
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '32px' }}>
<form onSubmit={handleSave} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div>
<label style={lbl}>Eyebrow-Text</label>
<input style={inp} value={eyebrowText} onChange={e => setEyebrowText(e.target.value)} placeholder="z. B. Klein, aber fein" />
</div>
<div>
<label style={lbl}>Absatz 1</label>
<textarea rows={4} style={{ ...inp, resize: 'vertical' }} value={absatz1} onChange={e => setAbsatz1(e.target.value)} />
</div>
<div>
<label style={lbl}>Absatz 2</label>
<textarea rows={4} style={{ ...inp, resize: 'vertical' }} value={absatz2} onChange={e => setAbsatz2(e.target.value)} />
</div>
<div>
<label style={lbl}>Sektionsbild</label>
{bildUrl && (
/* eslint-disable-next-line @next/next/no-img-element */
<img src={bildUrl} alt="Vorschau" style={{ width: '100%', height: '120px', objectFit: 'cover', borderRadius: '6px', marginBottom: '8px', border: '1px solid var(--border-color)' }} />
)}
<input type="file" accept="image/*" onChange={handleBildUpload} disabled={uploading} style={{ fontSize: '13px', color: 'var(--text-muted)' }} />
{uploading && <p style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '4px' }}>Hochladen</p>}
</div>
<button type="submit" disabled={saving} style={{ padding: '10px', borderRadius: '6px', background: 'var(--accent)', color: '#fff', fontWeight: 700, border: 'none', cursor: 'pointer', fontSize: '14px' }}>
{saving ? 'Speichert…' : saved ? '✓ Gespeichert' : 'Speichern'}
</button>
</form>
<div>
<label style={lbl}>Statistiken</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '12px' }}>
{stats.map(s => (
<div key={s.id} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: '8px', alignItems: 'center' }}>
<input style={inp} defaultValue={s.wert} onBlur={async e => { await fetch(`/api/admin/ueber-uns/stats/${s.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ wert: e.target.value }) }) }} />
<input style={inp} defaultValue={s.label} onBlur={async e => { await fetch(`/api/admin/ueber-uns/stats/${s.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ label: e.target.value }) }) }} />
<button onClick={() => deleteStat(s.id)} style={{ padding: '6px 10px', borderRadius: '6px', background: 'transparent', border: '1px solid rgba(220,38,38,0.3)', color: '#f87171', cursor: 'pointer', fontSize: '12px' }}></button>
</div>
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: '8px', alignItems: 'center' }}>
<input style={inp} value={newStat.wert} onChange={e => setNewStat(p => ({ ...p, wert: e.target.value }))} placeholder="Wert (z. B. 2022)" />
<input style={inp} value={newStat.label} onChange={e => setNewStat(p => ({ ...p, label: e.target.value }))} placeholder="Label (z. B. Gegründet)" />
<button type="button" onClick={addStat} style={{ padding: '8px 14px', borderRadius: '6px', background: 'var(--accent)', color: '#fff', fontWeight: 700, border: 'none', cursor: 'pointer', fontSize: '13px' }}>+</button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,119 @@
-- ================================================================
-- WEBSITE CMS Vollständige Migration (konsolidiert)
-- Alle Tabellen, Spalten und RLS-Policies in einer Datei.
-- Im Supabase SQL-Editor einmalig ausführen.
-- ================================================================
-- ── Hero Section ─────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS hero_content (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- Site / Navbar
site_name text NOT NULL DEFAULT 'Musterfirma',
site_tagline text NOT NULL DEFAULT '',
logo_path text DEFAULT NULL, -- Storage: site-assets
favicon_path text DEFAULT NULL, -- Storage: site-assets
-- Hero-Texte
eyebrow_text text NOT NULL DEFAULT 'Ihr Slogan hier',
headline1 text NOT NULL DEFAULT 'Wir lieben Qualität.',
headline2 text NOT NULL DEFAULT 'Ihre Kunden auch.',
subtext1 text NOT NULL DEFAULT '',
subtext2 text NOT NULL DEFAULT '',
-- Buttons
cta1_text text NOT NULL DEFAULT 'Jetzt anfragen',
cta1_href text NOT NULL DEFAULT '#kontakt',
cta2_text text NOT NULL DEFAULT 'Unsere Leistungen',
cta2_href text NOT NULL DEFAULT '#leistungen',
-- Hintergrundbild
bg_image_path text DEFAULT NULL, -- Storage: hero-bilder
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS hero_badges (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
text text NOT NULL,
reihenfolge int NOT NULL DEFAULT 0
);
-- ── Über uns / About ─────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS ueber_uns_content (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
eyebrow_text text NOT NULL DEFAULT 'Klein, aber fein',
absatz1 text NOT NULL DEFAULT '',
absatz2 text NOT NULL DEFAULT '',
bild_url text DEFAULT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS ueber_uns_stats (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
wert text NOT NULL,
label text NOT NULL,
reihenfolge int NOT NULL DEFAULT 0
);
-- ── Galerie ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS galerie_bilder (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
storage_path text NOT NULL, -- Storage: galerie-bilder
alt_text text NOT NULL DEFAULT '',
reihenfolge int NOT NULL DEFAULT 0
);
-- ── Kontakt ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS kontakt_info (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
telefon text NOT NULL DEFAULT '',
email text NOT NULL DEFAULT '',
adresse_zeile1 text NOT NULL DEFAULT '',
adresse_zeile2 text NOT NULL DEFAULT '',
formular_empfaenger text NOT NULL DEFAULT '',
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS kontakt_oeffnungszeiten (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tag text NOT NULL,
von text NOT NULL,
bis text NOT NULL,
reihenfolge int NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS kontakt_social (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
platform text NOT NULL,
url text NOT NULL,
reihenfolge int NOT NULL DEFAULT 0
);
-- ── RLS: Öffentliche Leseberechtigung (Anon-Key) ─────────────────
ALTER TABLE hero_content ENABLE ROW LEVEL SECURITY;
ALTER TABLE hero_badges ENABLE ROW LEVEL SECURITY;
ALTER TABLE ueber_uns_content ENABLE ROW LEVEL SECURITY;
ALTER TABLE ueber_uns_stats ENABLE ROW LEVEL SECURITY;
ALTER TABLE galerie_bilder ENABLE ROW LEVEL SECURITY;
ALTER TABLE kontakt_info ENABLE ROW LEVEL SECURITY;
ALTER TABLE kontakt_oeffnungszeiten ENABLE ROW LEVEL SECURITY;
ALTER TABLE kontakt_social ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "public read" ON hero_content;
DROP POLICY IF EXISTS "public read" ON hero_badges;
DROP POLICY IF EXISTS "public read" ON ueber_uns_content;
DROP POLICY IF EXISTS "public read" ON ueber_uns_stats;
DROP POLICY IF EXISTS "public read" ON galerie_bilder;
DROP POLICY IF EXISTS "public read" ON kontakt_info;
DROP POLICY IF EXISTS "public read" ON kontakt_oeffnungszeiten;
DROP POLICY IF EXISTS "public read" ON kontakt_social;
CREATE POLICY "public read" ON hero_content FOR SELECT TO public USING (true);
CREATE POLICY "public read" ON hero_badges FOR SELECT TO public USING (true);
CREATE POLICY "public read" ON ueber_uns_content FOR SELECT TO public USING (true);
CREATE POLICY "public read" ON ueber_uns_stats FOR SELECT TO public USING (true);
CREATE POLICY "public read" ON galerie_bilder FOR SELECT TO public USING (true);
CREATE POLICY "public read" ON kontakt_info FOR SELECT TO public USING (true);
CREATE POLICY "public read" ON kontakt_oeffnungszeiten FOR SELECT TO public USING (true);
CREATE POLICY "public read" ON kontakt_social FOR SELECT TO public USING (true);

File diff suppressed because one or more lines are too long