From db5e9bb5ce8ca2fd2a846a5bcca0ace22bd51ea8 Mon Sep 17 00:00:00 2001 From: MBO-Tech-IT Date: Mon, 18 May 2026 16:32:34 +0200 Subject: [PATCH] feat: add website CMS module (06) and update TypeScript build info Co-Authored-By: Claude Sonnet 4.6 --- modules/06-website-cms/TEMPLATE.md | 299 +++++++++++++++ .../files/app/admin/galerie/page.tsx | 16 + .../files/app/admin/hero/page.tsx | 16 + .../files/app/admin/kontakt/page.tsx | 16 + .../files/app/admin/passwort/page.tsx | 21 ++ .../files/app/admin/ueber-uns/page.tsx | 16 + .../files/app/api/admin/galerie/route.ts | 67 ++++ .../files/app/api/admin/galerie/sort/route.ts | 17 + .../app/api/admin/hero/badges/id/route.ts | 24 ++ .../files/app/api/admin/hero/badges/route.ts | 20 ++ .../files/app/api/admin/hero/bild/route.ts | 52 +++ .../files/app/api/admin/hero/favicon/route.ts | 49 +++ .../files/app/api/admin/hero/logo/route.ts | 49 +++ .../files/app/api/admin/hero/route.ts | 43 +++ .../admin/kontakt/oeffnungszeiten/id/route.ts | 24 ++ .../admin/kontakt/oeffnungszeiten/route.ts | 14 + .../files/app/api/admin/kontakt/route.ts | 36 ++ .../app/api/admin/kontakt/social/id/route.ts | 24 ++ .../app/api/admin/kontakt/social/route.ts | 14 + .../files/app/api/admin/passwort/route.ts | 30 ++ .../files/app/api/admin/ueber-uns/route.ts | 34 ++ .../app/api/admin/ueber-uns/stats/id/route.ts | 24 ++ .../app/api/admin/ueber-uns/stats/route.ts | 14 + .../app/api/admin/ueber-uns/upload/route.ts | 27 ++ .../files/components/GalerieAnzeige.tsx | 155 ++++++++ .../components/admin/GalerieVerwaltung.tsx | 86 +++++ .../files/components/admin/HeroVerwaltung.tsx | 339 ++++++++++++++++++ .../components/admin/KontaktVerwaltung.tsx | 109 ++++++ .../components/admin/PasswortAendern.tsx | 73 ++++ .../components/admin/UeberUnsVerwaltung.tsx | 109 ++++++ .../migrations/MIGRATIONS_WEBSITE_CMS.sql | 119 ++++++ tsconfig.tsbuildinfo | 2 +- 32 files changed, 1937 insertions(+), 1 deletion(-) create mode 100644 modules/06-website-cms/TEMPLATE.md create mode 100644 modules/06-website-cms/files/app/admin/galerie/page.tsx create mode 100644 modules/06-website-cms/files/app/admin/hero/page.tsx create mode 100644 modules/06-website-cms/files/app/admin/kontakt/page.tsx create mode 100644 modules/06-website-cms/files/app/admin/passwort/page.tsx create mode 100644 modules/06-website-cms/files/app/admin/ueber-uns/page.tsx create mode 100644 modules/06-website-cms/files/app/api/admin/galerie/route.ts create mode 100644 modules/06-website-cms/files/app/api/admin/galerie/sort/route.ts create mode 100644 modules/06-website-cms/files/app/api/admin/hero/badges/id/route.ts create mode 100644 modules/06-website-cms/files/app/api/admin/hero/badges/route.ts create mode 100644 modules/06-website-cms/files/app/api/admin/hero/bild/route.ts create mode 100644 modules/06-website-cms/files/app/api/admin/hero/favicon/route.ts create mode 100644 modules/06-website-cms/files/app/api/admin/hero/logo/route.ts create mode 100644 modules/06-website-cms/files/app/api/admin/hero/route.ts create mode 100644 modules/06-website-cms/files/app/api/admin/kontakt/oeffnungszeiten/id/route.ts create mode 100644 modules/06-website-cms/files/app/api/admin/kontakt/oeffnungszeiten/route.ts create mode 100644 modules/06-website-cms/files/app/api/admin/kontakt/route.ts create mode 100644 modules/06-website-cms/files/app/api/admin/kontakt/social/id/route.ts create mode 100644 modules/06-website-cms/files/app/api/admin/kontakt/social/route.ts create mode 100644 modules/06-website-cms/files/app/api/admin/passwort/route.ts create mode 100644 modules/06-website-cms/files/app/api/admin/ueber-uns/route.ts create mode 100644 modules/06-website-cms/files/app/api/admin/ueber-uns/stats/id/route.ts create mode 100644 modules/06-website-cms/files/app/api/admin/ueber-uns/stats/route.ts create mode 100644 modules/06-website-cms/files/app/api/admin/ueber-uns/upload/route.ts create mode 100644 modules/06-website-cms/files/components/GalerieAnzeige.tsx create mode 100644 modules/06-website-cms/files/components/admin/GalerieVerwaltung.tsx create mode 100644 modules/06-website-cms/files/components/admin/HeroVerwaltung.tsx create mode 100644 modules/06-website-cms/files/components/admin/KontaktVerwaltung.tsx create mode 100644 modules/06-website-cms/files/components/admin/PasswortAendern.tsx create mode 100644 modules/06-website-cms/files/components/admin/UeberUnsVerwaltung.tsx create mode 100644 modules/06-website-cms/migrations/MIGRATIONS_WEBSITE_CMS.sql diff --git a/modules/06-website-cms/TEMPLATE.md b/modules/06-website-cms/TEMPLATE.md new file mode 100644 index 0000000..b558062 --- /dev/null +++ b/modules/06-website-cms/TEMPLATE.md @@ -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, +})) + + +``` + +### 5. Admin-Navigation ergänzen +```tsx +Hero +Über uns +Galerie +Kontakt +Passwort +``` + +--- + +## 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. +``` diff --git a/modules/06-website-cms/files/app/admin/galerie/page.tsx b/modules/06-website-cms/files/app/admin/galerie/page.tsx new file mode 100644 index 0000000..c789415 --- /dev/null +++ b/modules/06-website-cms/files/app/admin/galerie/page.tsx @@ -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 ( +
+

+ Galerie verwalten +

+ +
+ ) +} diff --git a/modules/06-website-cms/files/app/admin/hero/page.tsx b/modules/06-website-cms/files/app/admin/hero/page.tsx new file mode 100644 index 0000000..a06fdcc --- /dev/null +++ b/modules/06-website-cms/files/app/admin/hero/page.tsx @@ -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 ( +
+

+ Hero-Sektion +

+ +
+ ) +} diff --git a/modules/06-website-cms/files/app/admin/kontakt/page.tsx b/modules/06-website-cms/files/app/admin/kontakt/page.tsx new file mode 100644 index 0000000..122dce8 --- /dev/null +++ b/modules/06-website-cms/files/app/admin/kontakt/page.tsx @@ -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 ( +
+

+ Kontaktdaten +

+ +
+ ) +} diff --git a/modules/06-website-cms/files/app/admin/passwort/page.tsx b/modules/06-website-cms/files/app/admin/passwort/page.tsx new file mode 100644 index 0000000..743efe0 --- /dev/null +++ b/modules/06-website-cms/files/app/admin/passwort/page.tsx @@ -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 ( +
+

+ Passwort ändern +

+

+ Angemeldet als {session.email} +

+ +
+ ) +} diff --git a/modules/06-website-cms/files/app/admin/ueber-uns/page.tsx b/modules/06-website-cms/files/app/admin/ueber-uns/page.tsx new file mode 100644 index 0000000..1f24139 --- /dev/null +++ b/modules/06-website-cms/files/app/admin/ueber-uns/page.tsx @@ -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 ( +
+

+ Über uns +

+ +
+ ) +} diff --git a/modules/06-website-cms/files/app/api/admin/galerie/route.ts b/modules/06-website-cms/files/app/api/admin/galerie/route.ts new file mode 100644 index 0000000..cfc87db --- /dev/null +++ b/modules/06-website-cms/files/app/api/admin/galerie/route.ts @@ -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 }) +} diff --git a/modules/06-website-cms/files/app/api/admin/galerie/sort/route.ts b/modules/06-website-cms/files/app/api/admin/galerie/sort/route.ts new file mode 100644 index 0000000..88840a3 --- /dev/null +++ b/modules/06-website-cms/files/app/api/admin/galerie/sort/route.ts @@ -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 }) +} diff --git a/modules/06-website-cms/files/app/api/admin/hero/badges/id/route.ts b/modules/06-website-cms/files/app/api/admin/hero/badges/id/route.ts new file mode 100644 index 0000000..7b31ea3 --- /dev/null +++ b/modules/06-website-cms/files/app/api/admin/hero/badges/id/route.ts @@ -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 }) +} diff --git a/modules/06-website-cms/files/app/api/admin/hero/badges/route.ts b/modules/06-website-cms/files/app/api/admin/hero/badges/route.ts new file mode 100644 index 0000000..678eafd --- /dev/null +++ b/modules/06-website-cms/files/app/api/admin/hero/badges/route.ts @@ -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 }) +} diff --git a/modules/06-website-cms/files/app/api/admin/hero/bild/route.ts b/modules/06-website-cms/files/app/api/admin/hero/bild/route.ts new file mode 100644 index 0000000..ea57ec7 --- /dev/null +++ b/modules/06-website-cms/files/app/api/admin/hero/bild/route.ts @@ -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 }) +} diff --git a/modules/06-website-cms/files/app/api/admin/hero/favicon/route.ts b/modules/06-website-cms/files/app/api/admin/hero/favicon/route.ts new file mode 100644 index 0000000..02c8205 --- /dev/null +++ b/modules/06-website-cms/files/app/api/admin/hero/favicon/route.ts @@ -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 }) +} diff --git a/modules/06-website-cms/files/app/api/admin/hero/logo/route.ts b/modules/06-website-cms/files/app/api/admin/hero/logo/route.ts new file mode 100644 index 0000000..30716fe --- /dev/null +++ b/modules/06-website-cms/files/app/api/admin/hero/logo/route.ts @@ -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 }) +} diff --git a/modules/06-website-cms/files/app/api/admin/hero/route.ts b/modules/06-website-cms/files/app/api/admin/hero/route.ts new file mode 100644 index 0000000..df7763c --- /dev/null +++ b/modules/06-website-cms/files/app/api/admin/hero/route.ts @@ -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 }) +} diff --git a/modules/06-website-cms/files/app/api/admin/kontakt/oeffnungszeiten/id/route.ts b/modules/06-website-cms/files/app/api/admin/kontakt/oeffnungszeiten/id/route.ts new file mode 100644 index 0000000..78de9d6 --- /dev/null +++ b/modules/06-website-cms/files/app/api/admin/kontakt/oeffnungszeiten/id/route.ts @@ -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 }) +} diff --git a/modules/06-website-cms/files/app/api/admin/kontakt/oeffnungszeiten/route.ts b/modules/06-website-cms/files/app/api/admin/kontakt/oeffnungszeiten/route.ts new file mode 100644 index 0000000..fe44465 --- /dev/null +++ b/modules/06-website-cms/files/app/api/admin/kontakt/oeffnungszeiten/route.ts @@ -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 }) +} diff --git a/modules/06-website-cms/files/app/api/admin/kontakt/route.ts b/modules/06-website-cms/files/app/api/admin/kontakt/route.ts new file mode 100644 index 0000000..98321e4 --- /dev/null +++ b/modules/06-website-cms/files/app/api/admin/kontakt/route.ts @@ -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 }) +} diff --git a/modules/06-website-cms/files/app/api/admin/kontakt/social/id/route.ts b/modules/06-website-cms/files/app/api/admin/kontakt/social/id/route.ts new file mode 100644 index 0000000..3bf603b --- /dev/null +++ b/modules/06-website-cms/files/app/api/admin/kontakt/social/id/route.ts @@ -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 }) +} diff --git a/modules/06-website-cms/files/app/api/admin/kontakt/social/route.ts b/modules/06-website-cms/files/app/api/admin/kontakt/social/route.ts new file mode 100644 index 0000000..f01bd0d --- /dev/null +++ b/modules/06-website-cms/files/app/api/admin/kontakt/social/route.ts @@ -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 }) +} diff --git a/modules/06-website-cms/files/app/api/admin/passwort/route.ts b/modules/06-website-cms/files/app/api/admin/passwort/route.ts new file mode 100644 index 0000000..25872fb --- /dev/null +++ b/modules/06-website-cms/files/app/api/admin/passwort/route.ts @@ -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 }) +} diff --git a/modules/06-website-cms/files/app/api/admin/ueber-uns/route.ts b/modules/06-website-cms/files/app/api/admin/ueber-uns/route.ts new file mode 100644 index 0000000..085b90e --- /dev/null +++ b/modules/06-website-cms/files/app/api/admin/ueber-uns/route.ts @@ -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 }) +} diff --git a/modules/06-website-cms/files/app/api/admin/ueber-uns/stats/id/route.ts b/modules/06-website-cms/files/app/api/admin/ueber-uns/stats/id/route.ts new file mode 100644 index 0000000..ddc7bcd --- /dev/null +++ b/modules/06-website-cms/files/app/api/admin/ueber-uns/stats/id/route.ts @@ -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 }) +} diff --git a/modules/06-website-cms/files/app/api/admin/ueber-uns/stats/route.ts b/modules/06-website-cms/files/app/api/admin/ueber-uns/stats/route.ts new file mode 100644 index 0000000..b769c5a --- /dev/null +++ b/modules/06-website-cms/files/app/api/admin/ueber-uns/stats/route.ts @@ -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 }) +} diff --git a/modules/06-website-cms/files/app/api/admin/ueber-uns/upload/route.ts b/modules/06-website-cms/files/app/api/admin/ueber-uns/upload/route.ts new file mode 100644 index 0000000..18ed823 --- /dev/null +++ b/modules/06-website-cms/files/app/api/admin/ueber-uns/upload/route.ts @@ -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}` }) +} diff --git a/modules/06-website-cms/files/components/GalerieAnzeige.tsx b/modules/06-website-cms/files/components/GalerieAnzeige.tsx new file mode 100644 index 0000000..cc85e2e --- /dev/null +++ b/modules/06-website-cms/files/components/GalerieAnzeige.tsx @@ -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(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(() => + bilder.slice(0, n).map(b => ({ bild: b, phase: 'idle' as const })) + ) + + const slotsRef = useRef(slots) + useEffect(() => { slotsRef.current = slots }, [slots]) + + const flipping = useRef(new Set()) + const poolRef = useRef([]) + 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 ( + <> + + + {/* Mobile */} +
+ {slots.map((slot, si) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {slot.bild.alt} +
+
+ ))} +
+ + {/* Desktop */} +
+ {Array.from({ length: numGroups }, (_, gi) => { + const bigLeft = gi % 2 === 0 + return ( +
+ {[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 ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {slot.bild.alt} +
+
+ ) + })} +
+ ) + })} +
+ + ) +} diff --git a/modules/06-website-cms/files/components/admin/GalerieVerwaltung.tsx b/modules/06-website-cms/files/components/admin/GalerieVerwaltung.tsx new file mode 100644 index 0000000..d72d95c --- /dev/null +++ b/modules/06-website-cms/files/components/admin/GalerieVerwaltung.tsx @@ -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([]) + const [uploading, setUploading] = useState(false) + const [loading, setLoading] = useState(true) + const fileRef = useRef(null) + + useEffect(() => { + fetch('/api/admin/galerie').then(r => r.json()).then(({ bilder: b }) => { setBilder(b ?? []); setLoading(false) }) + }, []) + + async function handleUpload(e: React.ChangeEvent) { + 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

Lade…

+ + return ( +
+
+ + + {bilder.length} Bilder · Reihenfolge per ↑↓ +
+ +
+ {bilder.map((bild, i) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {bild.alt_text} +
+ { await fetch('/api/admin/galerie', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: bild.id, alt_text: e.target.value }) }) }} + /> +
+
+ + +
+ +
+
+
+ ))} +
+
+ ) +} diff --git a/modules/06-website-cms/files/components/admin/HeroVerwaltung.tsx b/modules/06-website-cms/files/components/admin/HeroVerwaltung.tsx new file mode 100644 index 0000000..7978da5 --- /dev/null +++ b/modules/06-website-cms/files/components/admin/HeroVerwaltung.tsx @@ -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(DEFAULTS) + const [badges, setBadges] = useState([]) + 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(null) + const fileRef = useRef(null) + const logoRef = useRef(null) + const faviconRef = useRef(null) + const [logoPath, setLogoPath] = useState(null) + const [faviconPath, setFaviconPath] = useState(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

Lade…

+ + return ( +
+
+ + {/* Linke Spalte: Texte */} +
+
+

Navbar (Logo)

+
+
+ + setContent(p => ({ ...p, site_name: e.target.value }))} placeholder="z. B. Musterfirma" /> +
+
+ + setContent(p => ({ ...p, site_tagline: e.target.value }))} placeholder="z. B. Ihr Dienstleister aus der Region" /> +
+
+
+ +
+

Überschriften

+
+
+ + setContent(p => ({ ...p, eyebrow_text: e.target.value }))} placeholder="z. B. Ihr Slogan hier" /> +
+
+ + setContent(p => ({ ...p, headline1: e.target.value }))} placeholder="z. B. Wir lieben Qualität." /> +
+
+ + setContent(p => ({ ...p, headline2: e.target.value }))} placeholder="z. B. Ihre Kunden auch." /> +
+
+
+ +
+

Fließtexte

+
+
+ +