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:
parent
a060751f45
commit
db5e9bb5ce
|
|
@ -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.
|
||||
```
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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}` })
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in New Issue