Compare commits

...

5 Commits

Author SHA1 Message Date
MBO-Tech-IT 074b4eb5d1 feat: load homepage sections from supabase cms, fix passwort page auth
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 20:37:27 +02:00
MBO-Tech-IT 3eeea27b92 feat: Hero, About, Contact components accept CMS props
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 20:35:23 +02:00
MBO-Tech-IT b9aa6a0a54 feat: set MBO-Tech-IT defaults in hero and add CMS links to admin nav
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 20:33:37 +02:00
MBO-Tech-IT 20f8a0a0f0 fix: use admin_users table in passwort route
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 20:32:53 +02:00
MBO-Tech-IT 5b82ad93b3 feat: add website-cms module files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 20:32:24 +02:00
34 changed files with 1763 additions and 69 deletions

View File

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

16
app/admin/hero/page.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/admin-auth'
import { createServiceClient } from '@/lib/supabase'
export async function GET() {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const db = createServiceClient()
const [{ data: content }, { data: badges }] = await Promise.all([
db.from('hero_content').select('*').limit(1).single(),
db.from('hero_badges').select('*').order('reihenfolge'),
])
return NextResponse.json({ content: content ?? null, badges: badges ?? [] })
}
export async function PATCH(req: NextRequest) {
const check = await requireAdmin()
if (check instanceof NextResponse) return check
const body = await req.json()
const db = createServiceClient()
const { data: existing } = await db.from('hero_content').select('id').limit(1).single()
const fields = {
site_name: body.site_name ?? 'MBO-Tech-IT',
site_tagline: body.site_tagline ?? 'IT-Service Crailsheim',
eyebrow_text: body.eyebrow_text ?? 'Digital Denken. Lokal Handeln.',
headline1: body.headline1 ?? 'Ihre IT-Infrastruktur.',
headline2: body.headline2 ?? 'Professionell. Zuverlässig.',
subtext1: body.subtext1 ?? 'Ihr IT-Dienstleister in Crailsheim',
subtext2: body.subtext2 ?? '',
cta1_text: body.cta1_text ?? 'Jetzt anfragen',
cta1_href: body.cta1_href ?? '#contact',
cta2_text: body.cta2_text ?? 'Unsere Leistungen',
cta2_href: body.cta2_href ?? '#services',
updated_at: new Date().toISOString(),
}
const { error } = existing
? await db.from('hero_content').update(fields).eq('id', existing.id)
: await db.from('hero_content').insert(fields)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ success: true })
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server'
import bcrypt from 'bcryptjs'
import { requireAdmin } from '@/lib/admin-auth'
import { createServiceClient } from '@/lib/supabase'
export async function POST(req: NextRequest) {
const session = await requireAdmin()
if (session instanceof NextResponse) return session
const { currentPassword, newPassword } = await req.json()
if (!currentPassword || !newPassword) {
return NextResponse.json({ error: 'Alle Felder erforderlich.' }, { status: 400 })
}
if (newPassword.length < 8) {
return NextResponse.json({ error: 'Neues Passwort muss mindestens 8 Zeichen haben.' }, { status: 400 })
}
const db = createServiceClient()
const { data: admin } = await db.from('admin_users').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('admin_users').update({ password_hash: hash }).eq('id', session.id)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ success: true })
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import { createServiceClient } from "@/lib/supabase";
import Header from "@/components/Header"; import Header from "@/components/Header";
import Hero from "@/components/Hero"; import Hero from "@/components/Hero";
import TaglineBanner from "@/components/TaglineBanner"; import TaglineBanner from "@/components/TaglineBanner";
@ -9,12 +10,73 @@ import PaperlessSection from "@/components/PaperlessSection";
import About from "@/components/About"; import About from "@/components/About";
import Contact from "@/components/Contact"; import Contact from "@/components/Contact";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
import { GalerieAnzeige } from "@/components/GalerieAnzeige";
export const dynamic = "force-dynamic";
export default async function Home() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
let hero = null,
badges: { text: string }[] = [],
ueberUns = null,
stats: { wert: string; label: string }[] = [],
galerie: { id: string; storage_path: string; alt_text: string }[] = [],
kontakt = null,
oeffnung: { tag: string; von: string; bis: string }[] = [];
try {
const db = createServiceClient();
const [
heroResult,
badgesResult,
ueberUnsResult,
statsResult,
galerieResult,
kontaktResult,
oeffnungResult,
] = await Promise.allSettled([
db.from("hero_content").select("*").limit(1).single(),
db.from("hero_badges").select("*").order("reihenfolge"),
db.from("ueber_uns_content").select("*").limit(1).single(),
db.from("ueber_uns_stats").select("*").order("reihenfolge"),
db.from("galerie_bilder").select("*").order("reihenfolge"),
db.from("kontakt_info").select("*").limit(1).single(),
db.from("kontakt_oeffnungszeiten").select("*").order("reihenfolge"),
]);
hero = heroResult.status === "fulfilled" ? heroResult.value.data : null;
badges = badgesResult.status === "fulfilled" ? (badgesResult.value.data ?? []) : [];
ueberUns = ueberUnsResult.status === "fulfilled" ? ueberUnsResult.value.data : null;
stats = statsResult.status === "fulfilled" ? (statsResult.value.data ?? []) : [];
galerie = galerieResult.status === "fulfilled" ? (galerieResult.value.data ?? []) : [];
kontakt = kontaktResult.status === "fulfilled" ? kontaktResult.value.data : null;
oeffnung = oeffnungResult.status === "fulfilled" ? (oeffnungResult.value.data ?? []) : [];
} catch {
// Supabase nicht konfiguriert — Fallback-Werte greifen
}
const galerieBilder = galerie.map((b) => ({
id: b.id,
src: `${supabaseUrl}/storage/v1/object/public/galerie-bilder/${b.storage_path}`,
alt: b.alt_text,
}));
export default function Home() {
return ( return (
<main className="min-h-screen bg-[#f0f4f8] dark:bg-[#18212f]"> <main className="min-h-screen bg-[#f0f4f8] dark:bg-[#18212f]">
<Header /> <Header />
<Hero /> <Hero
eyebrowText={hero?.eyebrow_text}
headline1={hero?.headline1}
subtext1={hero?.subtext1}
subtext2={hero?.subtext2}
cta1Text={hero?.cta1_text}
cta1Href={hero?.cta1_href}
cta2Text={hero?.cta2_text}
cta2Href={hero?.cta2_href}
bgImagePath={hero?.bg_image_path}
badges={badges.length > 0 ? badges.map((b) => b.text) : undefined}
/>
<TaglineBanner /> <TaglineBanner />
<StatsBar /> <StatsBar />
<Services /> <Services />
@ -23,9 +85,30 @@ export default function Home() {
<DataSovereignty /> <DataSovereignty />
<TaglineBanner /> <TaglineBanner />
<Technologies /> <Technologies />
<About /> <About
eyebrowText={ueberUns?.eyebrow_text}
absatz1={ueberUns?.absatz1}
absatz2={ueberUns?.absatz2}
stats={stats.length > 0 ? stats : undefined}
/>
{galerieBilder.length > 0 && (
<>
<TaglineBanner /> <TaglineBanner />
<Contact /> <section className="py-16 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
<GalerieAnzeige bilder={galerieBilder} />
</div>
</section>
</>
)}
<TaglineBanner />
<Contact
telefon={kontakt?.telefon}
email={kontakt?.email}
adresseZeile1={kontakt?.adresse_zeile1}
adresseZeile2={kontakt?.adresse_zeile2}
oeffnungszeiten={oeffnung.length > 0 ? oeffnung : undefined}
/>
<Footer /> <Footer />
</main> </main>
); );

View File

@ -19,7 +19,19 @@ const highlights = [
}, },
]; ];
export default function About() { interface AboutProps {
eyebrowText?: string;
absatz1?: string;
absatz2?: string;
stats?: { wert: string; label: string }[];
}
export default function About({
eyebrowText = "Über uns",
absatz1 = "MBO-Tech-IT steht für über 30 Jahre IT-Erfahrung — angefangen bei der einfachen Hardware-Wartung, über den Aufbau großer Client-Server-Netzwerke, bis hin zu mehr als 20 Jahren Spezialisierung in der IT-Security.",
absatz2 = "Heute liegt der Fokus auf modernen Container-Technologien, Cloud-nativer Infrastruktur und Virtualisierung. Dieses breite Fundament ermöglicht es, Lösungen zu entwickeln, die nicht nur funktionieren — sondern auch sicher und langfristig tragfähig sind.",
stats,
}: AboutProps = {}) {
return ( return (
<section id="about" className="py-24 px-4 sm:px-6 lg:px-8 relative"> <section id="about" className="py-24 px-4 sm:px-6 lg:px-8 relative">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_right,rgba(37,99,235,0.06)_0%,transparent_60%)]" /> <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_right,rgba(37,99,235,0.06)_0%,transparent_60%)]" />
@ -29,23 +41,17 @@ export default function About() {
{/* Left: Text */} {/* Left: Text */}
<div> <div>
<span className="text-orange-400 font-mono text-xs font-bold tracking-[0.25em] uppercase"> <span className="text-orange-400 font-mono text-xs font-bold tracking-[0.25em] uppercase">
Über uns {eyebrowText}
</span> </span>
<h2 className="text-4xl sm:text-5xl font-black text-slate-900 dark:text-white mt-3 mb-6 leading-tight"> <h2 className="text-4xl sm:text-5xl font-black text-slate-900 dark:text-white mt-3 mb-6 leading-tight">
IT-Expertise,{" "} IT-Expertise,{" "}
<span className="text-gradient">die Sie weiterbringt</span> <span className="text-gradient">die Sie weiterbringt</span>
</h2> </h2>
<p className="text-slate-600 dark:text-slate-400 text-lg leading-relaxed mb-6"> <p className="text-slate-600 dark:text-slate-400 text-lg leading-relaxed mb-6">
MBO-Tech-IT steht für über <strong className="text-slate-900 dark:text-white">30 Jahre IT-Erfahrung</strong> &mdash; {absatz1}
angefangen bei der einfachen Hardware-Wartung, über den Aufbau großer
Client-Server-Netzwerke, bis hin zu mehr als 20 Jahren Spezialisierung
in der <strong className="text-slate-900 dark:text-white">IT-Security</strong>.
</p> </p>
<p className="text-slate-600 dark:text-slate-400 text-lg leading-relaxed mb-8"> <p className="text-slate-600 dark:text-slate-400 text-lg leading-relaxed mb-8">
Heute liegt der Fokus auf modernen Container-Technologien, Cloud-nativer {absatz2}
Infrastruktur und Virtualisierung. Dieses breite Fundament ermöglicht es,
Lösungen zu entwickeln, die nicht nur funktionieren &mdash; sondern auch
sicher und langfristig tragfähig sind.
</p> </p>
{/* Tagline callout */} {/* Tagline callout */}
@ -76,6 +82,17 @@ export default function About() {
</div> </div>
))} ))}
</div> </div>
{stats && stats.length > 0 && (
<div className="flex gap-8 mt-4">
{stats.map((s) => (
<div key={s.label}>
<div className="text-3xl font-black text-orange-400">{s.wert}</div>
<div className="text-sm text-slate-500 dark:text-slate-400">{s.label}</div>
</div>
))}
</div>
)}
</div> </div>
{/* Right: Process steps */} {/* Right: Process steps */}

View File

@ -2,6 +2,25 @@
import { useState } from "react"; import { useState } from "react";
interface OeffnungsZeit { tag: string; von: string; bis: string }
interface ContactProps {
telefon?: string;
email?: string;
adresseZeile1?: string;
adresseZeile2?: string;
oeffnungszeiten?: OeffnungsZeit[];
}
type Status = "idle" | "loading" | "success" | "error";
export default function Contact({
telefon = "+49 171 9345193",
email = "kontakt@mbo-tech-it.de",
adresseZeile1 = "Mörikestr. 2",
adresseZeile2 = "74564 Crailsheim",
oeffnungszeiten,
}: ContactProps = {}) {
const contactItems = [ const contactItems = [
{ {
icon: ( icon: (
@ -10,8 +29,8 @@ const contactItems = [
</svg> </svg>
), ),
label: "Telefon", label: "Telefon",
value: "+49 171 9345193", value: telefon,
href: "tel:+4917193451093", href: `tel:${telefon.replace(/\s/g, "")}`,
}, },
{ {
icon: ( icon: (
@ -20,8 +39,8 @@ const contactItems = [
</svg> </svg>
), ),
label: "E-Mail", label: "E-Mail",
value: "kontakt@mbo-tech-it.de", value: email,
href: "mailto:kontakt@mbo-tech-it.de", href: `mailto:${email}`,
}, },
{ {
icon: ( icon: (
@ -31,16 +50,13 @@ const contactItems = [
</svg> </svg>
), ),
label: "Standort", label: "Standort",
value: "Crailsheim, 74564", value: `${adresseZeile1}, ${adresseZeile2}`,
href: "https://maps.google.com/?q=Mörikestr.+2,+74564+Crailsheim", href: `https://maps.google.com/?q=${encodeURIComponent(adresseZeile1 + " " + adresseZeile2)}`,
}, },
]; ];
type Status = "idle" | "loading" | "success" | "error";
export default function Contact() {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [email, setEmail] = useState(""); const [emailInput, setEmailInput] = useState("");
const [betreff, setBetreff] = useState(""); const [betreff, setBetreff] = useState("");
const [nachricht, setNachricht] = useState(""); const [nachricht, setNachricht] = useState("");
const [status, setStatus] = useState<Status>("idle"); const [status, setStatus] = useState<Status>("idle");
@ -55,7 +71,7 @@ export default function Contact() {
const res = await fetch("/api/contact", { const res = await fetch("/api/contact", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, betreff, nachricht }), body: JSON.stringify({ name, email: emailInput, betreff, nachricht }),
}); });
const data = await res.json(); const data = await res.json();
@ -108,7 +124,7 @@ export default function Contact() {
onClick={() => { onClick={() => {
setStatus("idle"); setStatus("idle");
setName(""); setName("");
setEmail(""); setEmailInput("");
setBetreff(""); setBetreff("");
setNachricht(""); setNachricht("");
}} }}
@ -136,8 +152,8 @@ export default function Contact() {
<input <input
type="email" type="email"
required required
value={email} value={emailInput}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmailInput(e.target.value)}
placeholder="max@beispiel.de" placeholder="max@beispiel.de"
className="w-full px-4 py-3 rounded-xl bg-slate-50 dark:bg-[#111925] border border-slate-300 dark:border-gray-700 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-600 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-colors" className="w-full px-4 py-3 rounded-xl bg-slate-50 dark:bg-[#111925] border border-slate-300 dark:border-gray-700 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-600 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-colors"
/> />
@ -239,6 +255,22 @@ export default function Contact() {
</a> </a>
))} ))}
</div> </div>
{oeffnungszeiten && oeffnungszeiten.length > 0 && (
<div className="mt-6 pt-6 border-t border-slate-200 dark:border-slate-700">
<h4 className="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-3">
Öffnungszeiten
</h4>
<ul className="space-y-1">
{oeffnungszeiten.map((oz) => (
<li key={oz.tag} className="flex justify-between text-sm text-slate-600 dark:text-slate-400">
<span>{oz.tag}</span>
<span>{oz.von} {oz.bis}</span>
</li>
))}
</ul>
</div>
)}
</div> </div>
</div> </div>
</section> </section>

View File

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

View File

@ -1,10 +1,33 @@
// components/Hero.tsx
"use client"; "use client";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTypewriter } from "@/hooks/useTypewriter"; import { useTypewriter } from "@/hooks/useTypewriter";
export default function Hero() { interface HeroProps {
eyebrowText?: string;
headline1?: string;
subtext1?: string;
subtext2?: string;
cta1Text?: string;
cta1Href?: string;
cta2Text?: string;
cta2Href?: string;
bgImagePath?: string | null;
badges?: string[];
}
export default function Hero({
eyebrowText = "Digital Denken. Lokal Handeln.",
headline1 = "Ihre IT-Infrastruktur.",
subtext1 = "Ihr IT-Dienstleister in Crailsheim — egal ob zu Hause oder im Büro. Von Hard- & Software über Netzwerk & WLAN bis hin zu Webseiten & Webanwendungen: Wir lösen Ihre IT-Probleme persönlich und zum fairen Preis.",
subtext2 = "",
cta1Text = "Jetzt anfragen",
cta1Href = "#contact",
cta2Text = "Unsere Leistungen",
cta2Href = "#services",
bgImagePath = null,
badges = ["Docker", "Kubernetes", "Proxmox", "Hetzner Cloud", "Linux"],
}: HeroProps = {}) {
const words = useMemo(() => ["Professionell.", "DSGVO-konform.", "Zuverlässig.", "Skalierbar."], []); const words = useMemo(() => ["Professionell.", "DSGVO-konform.", "Zuverlässig.", "Skalierbar."], []);
const typed = useTypewriter(words); const typed = useTypewriter(words);
@ -20,48 +43,60 @@ export default function Hero() {
<div className="absolute top-1/4 left-1/4 w-72 h-72 bg-orange-500/5 rounded-full blur-3xl animate-pulse-slow" /> <div className="absolute top-1/4 left-1/4 w-72 h-72 bg-orange-500/5 rounded-full blur-3xl animate-pulse-slow" />
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-blue-600/5 rounded-full blur-3xl animate-pulse-slow" /> <div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-blue-600/5 rounded-full blur-3xl animate-pulse-slow" />
{bgImagePath && (
<div
className="absolute inset-0 bg-cover bg-center opacity-20"
style={{ backgroundImage: `url(${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/hero-bilder/${bgImagePath})` }}
/>
)}
<div className="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center"> <div className="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
{/* Tagline badge */} {/* Tagline badge */}
<div className="inline-flex items-center gap-2 px-5 py-2 rounded-full border border-orange-500/40 bg-orange-500/10 text-orange-400 text-xs font-black tracking-[0.3em] uppercase mb-8"> <div className="inline-flex items-center gap-2 px-5 py-2 rounded-full border border-orange-500/40 bg-orange-500/10 text-orange-400 text-xs font-black tracking-[0.3em] uppercase mb-8">
<span className="w-1.5 h-1.5 bg-orange-400 rounded-full animate-pulse" /> <span className="w-1.5 h-1.5 bg-orange-400 rounded-full animate-pulse" />
Digital Denken. Lokal Handeln. {eyebrowText}
</div> </div>
{/* Headline */} {/* Headline */}
<h1 className="text-5xl sm:text-6xl lg:text-7xl font-black text-slate-900 dark:text-white leading-tight mb-6 tracking-tight"> <h1 className="text-5xl sm:text-6xl lg:text-7xl font-black text-slate-900 dark:text-white leading-tight mb-6 tracking-tight">
Ihre IT-Infrastruktur.{" "} {headline1}{" "}
<span className="text-gradient"> <span className="text-gradient">
{typed} {typed}
<span className="animate-pulse">|</span> <span className="animate-pulse">|</span>
</span> </span>
<br />
Skalierbar. Zuverlässig.
</h1> </h1>
{/* Subheadline */} {/* Subheadline */}
{subtext1 && (
<p className="text-xl sm:text-2xl text-slate-600 dark:text-slate-400 max-w-3xl mx-auto mb-4 leading-relaxed"> <p className="text-xl sm:text-2xl text-slate-600 dark:text-slate-400 max-w-3xl mx-auto mb-4 leading-relaxed">
Ihr IT-Dienstleister in <strong className="text-slate-700 dark:text-slate-300">Crailsheim</strong> &mdash; egal ob zu Hause oder im Büro. Von Hard- &amp; Software über Netzwerk &amp; WLAN bis hin zu Webseiten &amp; Webanwendungen: Wir lösen Ihre IT-Probleme persönlich und zum fairen Preis. {subtext1}
</p> </p>
)}
{subtext2 && (
<p className="text-xl sm:text-2xl text-slate-600 dark:text-slate-400 max-w-3xl mx-auto mb-4 leading-relaxed">
{subtext2}
</p>
)}
{/* Tech pills */} {/* Tech pills */}
<div className="flex flex-wrap items-center justify-center gap-3 mb-10"> <div className="flex flex-wrap items-center justify-center gap-3 mb-10">
{["Docker", "Kubernetes", "Proxmox", "Hetzner Cloud", "Linux"].map((tech) => ( {badges.map((badge) => (
<span <span
key={tech} key={badge}
className="px-3 py-1 rounded-md bg-slate-100 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 text-slate-700 dark:text-slate-300 text-sm font-mono" className="px-3 py-1 rounded-md bg-slate-100 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 text-slate-700 dark:text-slate-300 text-sm font-mono"
> >
{tech} {badge}
</span> </span>
))} ))}
</div> </div>
{/* CTAs */} {/* CTAs */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-4"> <div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<a href="#contact" className="btn-primary w-full sm:w-auto px-8 py-4 text-lg"> <a href={cta1Href} className="btn-primary w-full sm:w-auto px-8 py-4 text-lg">
Projekt anfragen {cta1Text}
</a> </a>
<a href="#services" className="btn-secondary w-full sm:w-auto px-8 py-4 text-lg"> <a href={cta2Href} className="btn-secondary w-full sm:w-auto px-8 py-4 text-lg">
Services ansehen {cta2Text}
</a> </a>
</div> </div>

View File

@ -4,6 +4,11 @@ import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
const navLinks = [ const navLinks = [
{ href: "/admin/hero", label: "Hero" },
{ href: "/admin/ueber-uns", label: "Über uns" },
{ href: "/admin/galerie", label: "Galerie" },
{ href: "/admin/kontakt", label: "Kontakt" },
{ href: "/admin/passwort", label: "Passwort" },
{ href: "/admin/analytics", label: "Analytics" }, { href: "/admin/analytics", label: "Analytics" },
{ href: "/admin/statistik", label: "Statistik" }, { href: "/admin/statistik", label: "Statistik" },
{ href: "/admin/audit-logs", label: "Audit-Logs" }, { href: "/admin/audit-logs", label: "Audit-Logs" },

View File

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

View File

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

View File

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

View File

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

View File

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