From 12d31f54684228d8533867283083e927135cb31d Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Sat, 4 Jul 2026 00:58:33 +0200 Subject: [PATCH] feat(cms): add storage based website content management --- .gitignore | 4 + Dockerfile | 4 +- README.md | 39 +++- app/admin/system/page.tsx | 7 +- app/admin/website/page.tsx | 27 +-- app/api/admin/content/route.ts | 27 +++ app/api/content/settings/route.ts | 7 + app/datenschutz/page.tsx | 8 +- app/funkgeraete-service/page.tsx | 39 ++-- app/globals.css | 178 ++++++++++++++ app/impressum/page.tsx | 8 +- app/kontakt/page.tsx | 35 ++- app/layout.tsx | 11 +- app/leistungen/page.tsx | 30 +-- app/page.tsx | 55 ++--- app/seo.ts | 29 +++ app/ueber-uns/page.tsx | 37 +-- components/Footer.tsx | 69 +++++- components/Header.tsx | 43 +++- components/admin/ContentManager.tsx | 344 ++++++++++++++++++++++++++++ docker-compose.yml | 2 +- lib/content/defaults.ts | 176 ++++++++++++++ lib/content/service.ts | 153 +++++++++++++ lib/content/types.ts | 128 +++++++++++ lib/runtime/config.ts | 3 + lib/runtime/version.ts | 2 +- package-lock.json | 34 +-- package.json | 2 +- scripts/runtime-start.js | 4 +- storage/content/.gitkeep | 0 storage/content/backups/.gitkeep | 0 31 files changed, 1347 insertions(+), 158 deletions(-) create mode 100644 app/api/admin/content/route.ts create mode 100644 app/api/content/settings/route.ts create mode 100644 components/admin/ContentManager.tsx create mode 100644 lib/content/defaults.ts create mode 100644 lib/content/service.ts create mode 100644 lib/content/types.ts create mode 100644 storage/content/.gitkeep create mode 100644 storage/content/backups/.gitkeep diff --git a/.gitignore b/.gitignore index 204a542..b537029 100644 --- a/.gitignore +++ b/.gitignore @@ -11,8 +11,12 @@ data/site-settings.json data/smtp-settings.json storage/uploads/images/* storage/config/* +storage/content/* !storage/.gitkeep !storage/config/.gitkeep +!storage/content/.gitkeep +!storage/content/backups/ +!storage/content/backups/.gitkeep !storage/uploads/.gitkeep !storage/uploads/images/.gitkeep .DS_Store diff --git a/Dockerfile b/Dockerfile index c0bb863..94b7f82 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ FROM node:22-alpine AS runner WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 -ENV NEXT_PUBLIC_APP_VERSION=0.2.2 +ENV NEXT_PUBLIC_APP_VERSION=0.4.0 ENV DOCKER_ENV=true ENV PORT=3010 @@ -26,7 +26,7 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/scripts/runtime-start.js ./runtime-start.js -RUN mkdir -p /app/data /app/storage/config /app/storage/uploads/images /app/.next/cache \ +RUN mkdir -p /app/data /app/storage/config /app/storage/content/backups /app/storage/uploads/images /app/.next/cache \ && chown -R nextjs:nodejs /app/data /app/storage /app/.next/cache USER nextjs diff --git a/README.md b/README.md index 4c9a96d..591218f 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,12 @@ Eigenständige öffentliche Firmenwebsite für Funktechnik Schubert. Dieses Proj - TypeScript strict - Serverseitige API-Routen für Kontakt und Reparaturannahme - Geschützter Admin-Bereich unter `/admin` -- Lokale Foundation-Datenablage für Anfragen und Einstellungen +- Lokale Storage-Datenablage für Anfragen, Einstellungen, Medien und Website-Inhalte - Docker - Nginx/Reverse-Proxy-fähig - SEO Metadata, Sitemap und robots.txt -Version: `0.3.0` +Version: `0.4.0` ## Seiten @@ -125,6 +125,9 @@ Für lokale Entwicklung kann `AUTH_COOKIE_SECURE=false` bleiben. Produktiv muss - `POST /api/admin/settings` - `POST /api/admin/smtp` - `GET/POST /api/admin/media` +- `GET/PUT /api/admin/content` +- `GET /api/content/settings` +- `GET /api/media/[filename]` - `GET /api/health` Aktuell validieren die Routen serverseitig, geben klare JSON-Antworten zurück und schreiben nur technische Metadaten in Server-Logs. Es werden keine Nachrichteninhalte oder Tokens geloggt. Kontakt- und Reparaturanfragen werden lokal unter `data/*.json` gespeichert und nicht versioniert. @@ -138,14 +141,14 @@ Funktionen: - Dashboard mit Kennzahlen - Kontaktanfragen verwalten - Reparaturanfragen verwalten -- Website-Inhaltsverwaltung vorbereitet +- Website-Inhalte pflegen und sofort veröffentlichen - Firmendaten pflegen - SMTP-Konfiguration speichern und Testmail senden - Medien hochladen - SEO-Übersicht - Systemübersicht -Publishing von Website-Inhalten und Olympus-Übernahme sind bewusst noch nicht aktiv gekoppelt. SMTP-Versand fuer Kontakt- und Reparaturanfragen ist serverseitig angebunden, sofern `/admin/smtp` vollstaendig konfiguriert ist. +Website-Inhalte werden unter `/admin/website` gepflegt und beim Speichern sofort auf der öffentlichen Website wirksam. Die Content-Architektur ist bewusst über einen zentralen `ContentService` gekapselt, damit später Olympus CMS oder PostgreSQL als Backend angebunden werden können. SMTP-Versand fuer Kontakt- und Reparaturanfragen ist serverseitig angebunden, sofern `/admin/smtp` vollstaendig konfiguriert ist. ## Runtime Data @@ -171,6 +174,32 @@ Uploads liegen unter `storage/uploads/images/`, werden über `/api/media/[filena Alte Uploads aus `public/uploads/images` werden beim Start einmalig nach `storage/uploads/images` migriert, falls sie dort noch nicht vorhanden sind. +Website-Inhalte liegen unter `storage/content/` und werden nicht committed: + +```text +storage/content/home.json +storage/content/services.json +storage/content/radio-service.json +storage/content/about.json +storage/content/contact.json +storage/content/settings.json +storage/content/backups/ +``` + +Jede Seite besitzt eine eigene JSON-Datei. React-Komponenten lesen diese Dateien nicht direkt, sondern ausschließlich über `lib/content/service.ts`. Vor jedem Speichern erzeugt der Service eine Backup-Datei unter `storage/content/backups/` und behält pro Dokument die letzten 20 Versionen. Änderungen benötigen keinen Neustart und keinen Docker-Build. + +Bearbeitbar sind aktuell: + +- Startseite mit Hero, Call-to-Actions, Featureboxen, Leistungsboxen und Kundenversprechen +- Leistungen mit beliebig vielen sortierbaren und ein-/ausblendbaren Einträgen +- Funkgeräte-Service mit Einleitung, Marken, Fehlerbildern, Ablauf, Messmöglichkeiten, Abgleich und Reparaturtexten +- Über Uns mit Firmenbeschreibung, Werkstattbeschreibung und Philosophie +- Kontakt mit Kontaktinformationen, Öffnungszeiten, Maps-Link und Seitentexten +- Footer, Logo, Copyright, Header-CTA und globale SEO-Einstellungen +- SEO pro Seite inklusive Meta Title, Meta Description, Keywords, OpenGraph und Social Image + +Bilder für Logo und Social Image werden aus dem Medienbestand ausgewählt. Neue Uploads bleiben im privaten Storage und werden über `/api/media/[filename]` ausgeliefert. + ## Healthcheck ```bash @@ -188,7 +217,7 @@ Antwort: ```json { "status": "ok", - "version": "0.3.0", + "version": "0.4.0", "storage": "ok", "admin": "configured", "smtp": "configured", diff --git a/app/admin/system/page.tsx b/app/admin/system/page.tsx index c6f59ff..35d1cea 100644 --- a/app/admin/system/page.tsx +++ b/app/admin/system/page.tsx @@ -3,6 +3,7 @@ import AdminShell from "@/components/admin/AdminShell"; import { requireAdminSession } from "@/lib/admin/auth"; import { adminConfigurationStatus, appVersion, checkStorage, configDirectory, dataDirectory, isDockerEnvironment, olympusStatus, uploadDirectory } from "@/lib/runtime/config"; import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config"; +import { getContentSystemStatus } from "@/lib/content/service"; export default async function AdminSystemPage() { if (!await requireAdminSession()) redirect("/admin/login"); @@ -13,7 +14,7 @@ export default async function AdminSystemPage() { } catch { storage = "error"; } - const smtpSettings = await getSmtpSettings(); + const [smtpSettings, contentStatus] = await Promise.all([getSmtpSettings(), getContentSystemStatus()]); const smtpConfigured = isSmtpConfigured(smtpSettings); return ( @@ -37,6 +38,10 @@ export default async function AdminSystemPage() {
SMTP
{smtpConfigured ? "configured" : "missing"}
Letzte SMTP-Testmail
{smtpSettings.lastTestAt ? `${new Date(smtpSettings.lastTestAt).toLocaleString("de-DE")} (${smtpSettings.lastTestStatus})` : "keine"}
Letzter SMTP-Formularversand
{smtpSettings.lastDeliveryAt ? `${new Date(smtpSettings.lastDeliveryAt).toLocaleString("de-DE")} (${smtpSettings.lastDeliveryStatus})` : "keiner"}
+
Content Version
{contentStatus.version}
+
Letzte Content-Änderung
{contentStatus.lastModified ? new Date(contentStatus.lastModified).toLocaleString("de-DE") : "keine"}
+
Anzahl Seiten
{contentStatus.pageCount}
+
Anzahl Medien
{contentStatus.mediaCount}
diff --git a/app/admin/website/page.tsx b/app/admin/website/page.tsx index f3d309f..4fd4f99 100644 --- a/app/admin/website/page.tsx +++ b/app/admin/website/page.tsx @@ -1,18 +1,13 @@ import { redirect } from "next/navigation"; import AdminShell from "@/components/admin/AdminShell"; +import ContentManager from "@/components/admin/ContentManager"; import { requireAdminSession } from "@/lib/admin/auth"; - -const editablePages = [ - ["Startseite", "Hero, Vorteile, Textblöcke"], - ["Leistungen", "Funktechnik, Messtechnik, Servicebereiche"], - ["Funkgeräte-Service", "Diagnose, Abgleich, Fehlerbilder"], - ["Reparatur", "Reparaturannahme und Hinweise"], - ["Über uns", "Profil und Werkstattbeschreibung"], - ["Kontakt", "Kontakttext und Hinweise"], -] as const; +import { listMediaFiles } from "@/lib/admin/media"; +import { getAllContent } from "@/lib/content/service"; export default async function AdminWebsitePage() { if (!await requireAdminSession()) redirect("/admin/login"); + const [content, mediaFiles] = await Promise.all([getAllContent(), listMediaFiles()]); return ( @@ -20,19 +15,7 @@ export default async function AdminWebsitePage() {

Website

Website-Inhalte

-
-

Editierbare Inhalte

-

Diese Foundation bereitet die Inhaltsverwaltung vor. Die öffentliche Website bleibt bis zur finalen Publishing-Funktion weiterhin quellcodebasiert.

-
- {editablePages.map(([title, description]) => ( -
-

{title}

-

{description}

- -
- ))} -
-
+
); } diff --git a/app/api/admin/content/route.ts b/app/api/admin/content/route.ts new file mode 100644 index 0000000..dc2c9b8 --- /dev/null +++ b/app/api/admin/content/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import { requireAdminSession } from "@/lib/admin/auth"; +import { getAllContent, saveContent } from "@/lib/content/service"; +import type { ContentDocuments, ContentKey } from "@/lib/content/types"; + +const keys: ContentKey[] = ["home", "services", "radio-service", "about", "contact", "settings"]; + +function isContentKey(value: unknown): value is ContentKey { + return typeof value === "string" && keys.includes(value as ContentKey); +} + +export async function GET() { + if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 }); + return NextResponse.json({ content: await getAllContent() }); +} + +export async function PUT(request: Request) { + if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 }); + + const body = await request.json() as { key?: unknown; content?: unknown }; + if (!isContentKey(body.key) || !body.content) { + return NextResponse.json({ message: "Ungültige Content-Anfrage." }, { status: 400 }); + } + + const saved = await saveContent(body.key, body.content as ContentDocuments[typeof body.key]); + return NextResponse.json({ message: "Inhalt gespeichert.", key: body.key, content: saved }); +} diff --git a/app/api/content/settings/route.ts b/app/api/content/settings/route.ts new file mode 100644 index 0000000..9da8ad9 --- /dev/null +++ b/app/api/content/settings/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from "next/server"; +import { getContent } from "@/lib/content/service"; + +export async function GET() { + const settings = await getContent("settings"); + return NextResponse.json({ settings }); +} diff --git a/app/datenschutz/page.tsx b/app/datenschutz/page.tsx index 265ff71..31e2d04 100644 --- a/app/datenschutz/page.tsx +++ b/app/datenschutz/page.tsx @@ -10,15 +10,15 @@ export default function DatenschutzPage() { Informationen zur Verarbeitung personenbezogener Daten.
-

TODO: Rechtliche Angaben ergänzen

+

Datenschutzhinweise müssen vor produktiver Veröffentlichung durch den Betreiber ergänzt und rechtlich geprüft werden.

Verantwortlicher

-

TODO: Rechtliche Angaben ergänzen

+

Die Angaben zum Verantwortlichen werden durch den Betreiber gepflegt.

Kontakt- und Reparaturanfragen

Die Formulare erfassen Angaben, die zur Bearbeitung der jeweiligen Anfrage erforderlich sind. Eine produktive Datenschutzerklärung muss vor Veröffentlichung rechtlich geprüft und ergänzt werden.

Hosting und Server-Logs

-

TODO: Rechtliche Angaben ergänzen

+

Informationen zum Hosting und zur Verarbeitung technischer Serverdaten werden durch den Betreiber gepflegt.

Betroffenenrechte

-

TODO: Rechtliche Angaben ergänzen

+

Hinweise zu Betroffenenrechten werden durch den Betreiber rechtlich geprüft und gepflegt.

diff --git a/app/funkgeraete-service/page.tsx b/app/funkgeraete-service/page.tsx index ef23bd7..2f58d11 100644 --- a/app/funkgeraete-service/page.tsx +++ b/app/funkgeraete-service/page.tsx @@ -1,33 +1,40 @@ -import type { Metadata } from "next"; import Link from "next/link"; import PageHero from "@/components/PageHero"; -import { createMetadata } from "../seo"; +import { getContent } from "@/lib/content/service"; +import { createContentMetadata } from "../seo"; -export const metadata: Metadata = createMetadata("Funkgeräte-Service", "Werkstattservice für CB-Funk, Amateurfunk und Funkgeräte-Abgleich.", "/funkgeraete-service"); +export const dynamic = "force-dynamic"; + +export async function generateMetadata() { + const content = await getContent("radio-service"); + return createContentMetadata(content.seo); +} + +export default async function RadioServicePage() { + const content = await getContent("radio-service"); -export default function RadioServicePage() { return ( <> - - Funkgeräte zeigen Fehler oft erst im Zusammenspiel von Empfang, Sendeteil, Versorgung, Antennenanpassung, Bedienung und Abgleich. Der Service betrachtet diese Zusammenhänge strukturiert. + + {content.subtitle}
-

Typische Fehlerbilder

+

{content.listTitle}

    -
  • Kein oder schwacher Empfang
  • -
  • Keine, leise oder verzerrte Modulation
  • -
  • Frequenzversatz, PLL-Rasten oder instabile Kanäle
  • -
  • Sendeprobleme, schwankende Leistung oder auffällige Erwärmung
  • -
  • Audio-/NF-Probleme, Displayfehler oder Aussetzer durch Bedienelemente
  • -
  • Unklare Vorarbeiten, fehlende Serviceunterlagen oder bereits geöffnete Geräte
  • + {content.symptoms.map((symptom) =>
  • {symptom}
  • )}
-

Messung und Einordnung

-

Eine gute Reparatur beginnt mit vollständigen Angaben zu Gerät, Fehlerbild und Vorgeschichte. Danach folgen technische Einschätzung, Prüfung mit geeigneter Messtechnik und die weitere Abstimmung.

- Reparatur anfragen +

{content.cta.text}

+

{content.intro}

+
    + {content.measurements.map((measurement) =>
  • {measurement}
  • )} +
+

{content.alignment}

+

{content.repair}

+ {content.cta.primary.label}
diff --git a/app/globals.css b/app/globals.css index 25bc51b..8ffb79d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -985,6 +985,171 @@ h3 { overflow-wrap: anywhere; } +.content-manager { + display: grid; + gap: 18px; +} + +.content-editor-layout { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 18px; + align-items: start; +} + +.content-tabs { + position: sticky; + top: 18px; + display: grid; + gap: 6px; +} + +.content-tabs button, +.content-preview-toolbar button { + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; + color: var(--navy); + font-weight: 800; + padding: 10px 12px; + text-align: left; +} + +.content-tabs button[aria-current="page"], +.content-preview-toolbar button[aria-current="true"] { + border-color: var(--navy); + background: var(--navy); + color: #fff; +} + +.content-editor { + display: grid; + gap: 16px; + min-width: 0; +} + +.content-editor-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.content-editor-top h2 { + margin-bottom: 0; +} + +.content-editor-heading { + margin: 14px 0 0; + border-top: 1px solid var(--line); + padding-top: 16px; + color: var(--navy); +} + +.content-field-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.content-field-grid label, +.content-editor label { + display: grid; + gap: 7px; + color: var(--ink); + font-size: 14px; + font-weight: 800; +} + +.content-field-grid input, +.content-field-grid select, +.content-field-grid textarea, +.content-editor input, +.content-editor select, +.content-editor textarea { + width: 100%; + border: 1px solid #cfd8e5; + border-radius: 8px; + background: #fff; + padding: 11px 12px; + color: var(--ink); +} + +.content-editor textarea { + min-height: 110px; + resize: vertical; +} + +.content-repeaters { + display: grid; + gap: 12px; +} + +.content-repeater { + display: grid; + gap: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: #f8fafc; + padding: 14px; +} + +.content-preview-card { + overflow: hidden; +} + +.content-preview-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; +} + +.content-preview-toolbar h2 { + margin-bottom: 0; +} + +.content-preview-toolbar div { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.content-preview { + width: 100%; + max-width: 100%; + min-height: 320px; + border: 1px solid var(--line); + border-radius: 8px; + background: + linear-gradient(90deg, rgba(6, 24, 52, 0.95), rgba(8, 42, 96, 0.72)), + url("/workbench-signal.svg"), + #07172d; + background-size: cover; + color: #fff; + padding: 34px; +} + +.content-preview.tablet { + max-width: 760px; + margin: 0 auto; +} + +.content-preview.mobile { + max-width: 390px; + margin: 0 auto; +} + +.content-preview h1 { + font-size: clamp(30px, 5vw, 52px); +} + +.content-preview .lead, +.content-preview p { + color: rgba(255, 255, 255, 0.82); +} + @media (max-width: 1080px) and (min-width: 861px) { .brand-logo { width: 236px; @@ -1095,4 +1260,17 @@ h3 { grid-template-columns: 1fr; gap: 4px; } + + .content-editor-layout { + grid-template-columns: 1fr; + } + + .content-tabs { + position: static; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .content-field-grid { + grid-template-columns: 1fr; + } } diff --git a/app/impressum/page.tsx b/app/impressum/page.tsx index b413f8f..688fb72 100644 --- a/app/impressum/page.tsx +++ b/app/impressum/page.tsx @@ -10,13 +10,13 @@ export default function ImpressumPage() { Rechtliche Angaben für die öffentliche Firmenwebsite.
-

TODO: Rechtliche Angaben ergänzen

+

Rechtliche Angaben müssen vor produktiver Veröffentlichung durch den Betreiber ergänzt und geprüft werden.

Anbieterkennzeichnung

-

TODO: Rechtliche Angaben ergänzen

+

Die vollständige Anbieterkennzeichnung wird durch den Betreiber gepflegt.

Kontakt

-

TODO: Rechtliche Angaben ergänzen

+

Die rechtlich relevanten Kontaktdaten werden durch den Betreiber gepflegt.

Umsatzsteuer / Registerangaben

-

TODO: Rechtliche Angaben ergänzen

+

Umsatzsteuer- und Registerangaben werden durch den Betreiber gepflegt, sofern sie erforderlich sind.

diff --git a/app/kontakt/page.tsx b/app/kontakt/page.tsx index 67ac7d2..4dd02b2 100644 --- a/app/kontakt/page.tsx +++ b/app/kontakt/page.tsx @@ -1,21 +1,40 @@ -import type { Metadata } from "next"; import PageHero from "@/components/PageHero"; import ContactForm from "@/components/ContactForm"; -import { createMetadata } from "../seo"; +import { getContent } from "@/lib/content/service"; +import { createContentMetadata } from "../seo"; -export const metadata: Metadata = createMetadata("Kontakt", "Kontakt zu Funktechnik Schubert aufnehmen.", "/kontakt"); +export const dynamic = "force-dynamic"; + +export async function generateMetadata() { + const content = await getContent("contact"); + return createContentMetadata(content.seo); +} + +export default async function ContactPage() { + const content = await getContent("contact"); + const contactDetails = [ + content.phone && `Telefon: ${content.phone}`, + content.email && `E-Mail: ${content.email}`, + content.address && `Adresse: ${content.address}`, + content.openingHours && `Öffnungszeiten: ${content.openingHours}`, + ].filter(Boolean); -export default function ContactPage() { return ( <> - - Beschreiben Sie kurz Ihr Anliegen. Für Reparaturen nutzen Sie idealerweise die strukturierte Reparaturannahme. + + {content.subtitle}
-

Direkt und technisch

-

Je genauer Gerät, Anliegen und gewünschte Unterstützung beschrieben werden, desto gezielter kann die Rückmeldung erfolgen.

+

{content.cta.text}

+

{content.heroText}

+ {contactDetails.length > 0 && ( +
    + {contactDetails.map((detail) =>
  • {detail}
  • )} +
+ )} + {content.googleMapsLink &&

Karte öffnen

}
diff --git a/app/layout.tsx b/app/layout.tsx index 55e8ba9..565bf30 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,12 +1,13 @@ import type { Metadata } from "next"; import SiteFrame from "@/components/SiteFrame"; import "./globals.css"; -import { createMetadata } from "./seo"; +import { getContent } from "@/lib/content/service"; +import { createContentMetadata } from "./seo"; -export const metadata: Metadata = createMetadata( - "Funktechnik Schubert", - "Service, Reparatur, Diagnose und Abgleich von Funktechnik, CB-Funk, Amateurfunk und Kommunikationstechnik.", -); +export async function generateMetadata(): Promise { + const settings = await getContent("settings"); + return createContentMetadata(settings.seo); +} export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { return ( diff --git a/app/leistungen/page.tsx b/app/leistungen/page.tsx index 7794dad..20afa89 100644 --- a/app/leistungen/page.tsx +++ b/app/leistungen/page.tsx @@ -1,27 +1,29 @@ -import type { Metadata } from "next"; import PageHero from "@/components/PageHero"; import ServiceCard from "@/components/ServiceCard"; -import { createMetadata } from "../seo"; +import { getContent } from "@/lib/content/service"; +import { createContentMetadata } from "../seo"; -export const metadata: Metadata = createMetadata("Leistungen", "Funkgeräte-Service, Diagnose, Abgleich und Dokumentation für Kommunikationstechnik.", "/leistungen"); +export const dynamic = "force-dynamic"; -const services = [ - ["Funkgeräte-Service", "Service für CB-Funk, Amateurfunk, Betriebsfunk sowie ältere und moderne Funkgeräte. Dazu gehören Sichtprüfung, Funktionsprüfung und eine technische Einschätzung des Gerätezustands."], - ["Fehlersuche & Reparatur", "Strukturierte Analyse bei fehlender Modulation, Frequenzabweichung, PLL-Problemen, Empfangs- oder Sendeproblemen, Audio-/NF-Fehlern und Anzeige- oder Displayfehlern."], - ["Abgleich & Prüfung", "Frequenzabgleich, Hub- und Modulationsprüfung, Sendeleistung, Empfindlichkeit und Funktionsprüfung nach technischen Unterlagen und sinnvoller Werkstattpraxis."], - ["Messtechnik & Diagnose", "Systematische Fehlersuche mit geeigneter Messtechnik wie Oszilloskop, Frequenzzähler, Signalgenerator, RF-Messung und Modulationsmessung."], - ["Serviceunterlagen", "Sorgfältige Einordnung von Schaltplänen, Service Manuals, Abgleichanleitungen, Dokumentation und Reparaturhinweisen für nachvollziehbare Arbeitsschritte."], -] as const; +export async function generateMetadata() { + const content = await getContent("services"); + return createContentMetadata(content.seo); +} + +export default async function ServicesPage() { + const content = await getContent("services"); + const services = content.services + .filter((service) => service.visible) + .sort((left, right) => left.sortOrder - right.sortOrder); -export default function ServicesPage() { return ( <> - - Technische Unterstützung für Geräte, bei denen Diagnose, Messtechnik, Erfahrung und saubere Dokumentation zählen. + + {content.subtitle}
- {services.map(([title, text]) => {text})} + {services.map((service) => {service.description})}
diff --git a/app/page.tsx b/app/page.tsx index 859a1a6..4d43644 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,26 +1,32 @@ import Link from "next/link"; import ServiceCard from "@/components/ServiceCard"; +import { getContent } from "@/lib/content/service"; +import { createContentMetadata } from "./seo"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata() { + const content = await getContent("home"); + return createContentMetadata(content.seo); +} + +export default async function HomePage() { + const content = await getContent("home"); -export default function HomePage() { return ( <>
-

Funktechnik, Messtechnik, Werkstattservice

-

Funktechnik Schubert

-

Service, Reparatur und Diagnose für Funkgeräte, Messtechnik und Kommunikationstechnik.

-

Von CB-Funk und Amateurfunk bis zur professionellen Funktechnik: Wir unterstützen bei Fehlersuche, Abgleich, Modulationsproblemen, Frequenzproblemen und technischer Dokumentation.

+

{content.eyebrow}

+

{content.title}

+

{content.subtitle}

+

{content.heroText}

- CB-Funk - Amateurfunk - Messtechnik - Abgleich - Diagnose - Service Manuals + {content.badges.map((badge) => {badge})}
- Reparatur anfragen - Leistungen ansehen + {content.cta.primary.label} + {content.cta.secondary.label}
@@ -29,13 +35,11 @@ export default function HomePage() {

Leistungsbereiche

-

Werkstattservice für Funktechnik und Messtechnik

-

Von der ersten Fehlerbeschreibung bis zur dokumentierten Prüfung: Funktechnik Schubert verbindet technische Erfahrung, geeignete Messmittel und strukturierte Kommunikation.

+

{content.cta.text}

+

{content.intro}

- Sichtprüfung, Funktionsprüfung und technische Beurteilung für CB-Funk, Amateurfunk, Betriebsfunk sowie ältere und moderne Geräte. - Systematische Diagnose bei Empfangsproblemen, Sendeproblemen, PLL-Fehlern, Frequenzabweichungen und Audio-/NF-Störungen. - Frequenzabgleich, Hub- und Modulationsprüfung, Sendeleistung, Empfindlichkeit und RF-Messung mit geeigneter Messtechnik. + {content.features.map((feature) => {feature.text})}
@@ -43,20 +47,17 @@ export default function HomePage() {
-

Persönlicher Support

-

Direkte technische Einschätzung statt anonymer Abwicklung

-

Viele Fehlerbilder lassen sich bereits mit einer klaren Beschreibung und den richtigen Gerätedaten eingrenzen. Die Reparaturannahme fragt deshalb die wichtigsten technischen Details strukturiert ab.

+

{content.promise.eyebrow}

+

{content.promise.title}

+

{content.promise.text}

- Reparaturformular öffnen + {content.promise.button.label}
-

Warum Funktechnik Schubert?

+

{content.promise.title}

    -
  • Technischer Fokus auf Funkgeräte und Kommunikationstechnik
  • -
  • Nachvollziehbare Diagnose mit Blick auf Messwerte und Fehlerbild
  • -
  • Sauberer Umgang mit Service Manuals, Schaltplänen und Abgleichanleitungen
  • -
  • Klare Rückmeldung zu Zustand, Aufwand und sinnvollen nächsten Schritten
  • + {content.promise.bullets.map((item) =>
  • {item}
  • )}
diff --git a/app/seo.ts b/app/seo.ts index 0d2de93..2baa7ef 100644 --- a/app/seo.ts +++ b/app/seo.ts @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import type { SeoContent } from "@/lib/content/types"; const siteName = "Funktechnik Schubert"; const defaultTitle = "Funktechnik Schubert | Funkgeräte-Service & Messtechnik"; @@ -59,4 +60,32 @@ export function createMetadata(title: string, description: string, path = "/"): }; } +export function createContentMetadata(seo: SeoContent): Metadata { + const path = seo.canonicalUrl || "/"; + const url = new URL(path, siteUrl).toString(); + const title = seo.metaTitle || siteName; + const description = seo.metaDescription || defaultTitle; + + return { + ...createMetadata(title, description, path), + keywords: seo.keywords ? seo.keywords.split(",").map((keyword) => keyword.trim()).filter(Boolean) : keywords, + openGraph: { + type: "website", + locale: "de_DE", + url, + siteName, + title: seo.openGraphTitle || title, + description: seo.openGraphDescription || description, + images: [ + { + url: seo.socialImage || "/funktechnik_schubert_logo.jpg", + width: 1200, + height: 630, + alt: seo.openGraphTitle || title, + }, + ], + }, + }; +} + export { defaultTitle, siteName, siteUrl }; diff --git a/app/ueber-uns/page.tsx b/app/ueber-uns/page.tsx index 2b68689..74a8c31 100644 --- a/app/ueber-uns/page.tsx +++ b/app/ueber-uns/page.tsx @@ -1,25 +1,34 @@ -import type { Metadata } from "next"; import PageHero from "@/components/PageHero"; -import { createMetadata } from "../seo"; +import { getContent } from "@/lib/content/service"; +import { createContentMetadata } from "../seo"; -export const metadata: Metadata = createMetadata("Über uns", "Funktechnik Schubert steht für technischen Service und klare Kommunikation.", "/ueber-uns"); +export const dynamic = "force-dynamic"; + +export async function generateMetadata() { + const content = await getContent("about"); + return createContentMetadata(content.seo); +} + +export default async function AboutPage() { + const content = await getContent("about"); + const blocks = content.paragraphs.length > 0 ? content.paragraphs : [ + { title: content.companyDescription, text: content.workshopDescription }, + { title: content.philosophy, text: content.intro }, + ]; -export default function AboutPage() { return ( <> - - Funktechnik Schubert richtet sich an Kunden, die bei Funkgeräten und Kommunikationstechnik eine persönliche, technische Einschätzung suchen. + + {content.subtitle}
-
-

Arbeitsweise

-

Im Mittelpunkt stehen saubere Diagnose, transparente Kommunikation und der respektvolle Umgang mit bestehenden Geräten und Serviceunterlagen.

-
-
-

Fokus

-

Der Schwerpunkt liegt auf Funktechnik, Elektronikservice, Abgleichfragen und technischer Unterstützung rund um Kommunikationstechnik.

-
+ {blocks.map((block) => ( +
+

{block.title}

+

{block.text}

+
+ ))}
diff --git a/components/Footer.tsx b/components/Footer.tsx index 9c11eb4..926df5b 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -1,33 +1,82 @@ +"use client"; + import Image from "next/image"; import Link from "next/link"; +import { useEffect, useState } from "react"; import { appVersion } from "@/lib/runtime/version"; +import type { SettingsContent } from "@/lib/content/types"; + +const fallbackSettings: SettingsContent = { + siteName: "Funktechnik Schubert", + logoUrl: "/funktechnik_schubert_logo.jpg", + footerShortText: "Service und technische Unterstützung für Funkgeräte, Messtechnik und Kommunikationstechnik.", + copyright: "© Funktechnik Schubert", + footerLinks: [ + { label: "Leistungen", href: "/leistungen" }, + { label: "Funkgeräte-Service", href: "/funkgeraete-service" }, + { label: "Reparatur", href: "/reparatur" }, + { label: "Kontakt", href: "/kontakt" }, + ], + legalLinks: [ + { label: "Impressum", href: "/impressum" }, + { label: "Datenschutz", href: "/datenschutz" }, + ], + headerCta: { label: "Reparatur anfragen", href: "/reparatur" }, + seo: { + metaTitle: "Funktechnik Schubert", + metaDescription: "", + keywords: "", + openGraphTitle: "", + openGraphDescription: "", + socialImage: "/funktechnik_schubert_logo.jpg", + canonicalUrl: "/", + }, + updatedAt: "", +}; export default function Footer() { + const [settings, setSettings] = useState(fallbackSettings); + + useEffect(() => { + let active = true; + + async function loadSettings() { + try { + const response = await fetch("/api/content/settings"); + const result = await response.json() as { settings?: SettingsContent }; + if (active && response.ok && result.settings) setSettings(result.settings); + } catch { + if (active) setSettings(fallbackSettings); + } + } + + void loadSettings(); + return () => { + active = false; + }; + }, []); + return (
Funktechnik Schubert -

Funktechnik Schubert – Service und technische Unterstützung für Funkgeräte, Messtechnik und Kommunikationstechnik.

-

© Funktechnik Schubert · v{appVersion}

+

{settings.footerShortText}

+

{settings.copyright} · v{appVersion}

Website

-

Leistungen

-

Funkgeräte-Service

-

Reparatur

-

Kontakt

+ {settings.footerLinks.map((link) =>

{link.label}

)}

Rechtliches

-

Impressum

-

Datenschutz

+ {settings.legalLinks.map((link) =>

{link.label}

)}
diff --git a/components/Header.tsx b/components/Header.tsx index 105fd6e..b48d5ac 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -3,7 +3,8 @@ import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { useState } from "react"; +import { useEffect, useState } from "react"; +import type { SettingsContent } from "@/lib/content/types"; const navigation = [ ["Start", "/"], @@ -14,17 +15,49 @@ const navigation = [ ["Kontakt", "/kontakt"], ] as const; +const fallbackSettings = { + siteName: "Funktechnik Schubert", + logoUrl: "/funktechnik_schubert_logo.jpg", + headerCta: { label: "Reparatur anfragen", href: "/reparatur" }, +}; + export default function Header() { const pathname = usePathname(); const [menuOpen, setMenuOpen] = useState(false); + const [settings, setSettings] = useState(fallbackSettings); + + useEffect(() => { + let active = true; + + async function loadSettings() { + try { + const response = await fetch("/api/content/settings"); + const result = await response.json() as { settings?: SettingsContent }; + if (active && response.ok && result.settings) { + setSettings({ + siteName: result.settings.siteName, + logoUrl: result.settings.logoUrl, + headerCta: result.settings.headerCta, + }); + } + } catch { + if (active) setSettings(fallbackSettings); + } + } + + void loadSettings(); + return () => { + active = false; + }; + }, []); return (
- setMenuOpen(false)}> + setMenuOpen(false)}> Funktechnik Schubert ))} - Reparatur anfragen + {settings.headerCta.label}
); diff --git a/components/admin/ContentManager.tsx b/components/admin/ContentManager.tsx new file mode 100644 index 0000000..af1ef6a --- /dev/null +++ b/components/admin/ContentManager.tsx @@ -0,0 +1,344 @@ +"use client"; + +import { useMemo, useState } from "react"; +import type { MediaFile } from "@/lib/admin/media"; +import type { ContentDocuments, ContentKey, LinkContent, PageContent, ServiceContent, TextBlockContent } from "@/lib/content/types"; + +type Viewport = "desktop" | "tablet" | "mobile"; +type ContentTabId = ContentKey | "footer" | "global-seo" | "general-settings"; + +const tabs: Array<{ id: ContentTabId; key: ContentKey; label: string }> = [ + { id: "home", key: "home", label: "Startseite" }, + { id: "services", key: "services", label: "Leistungen" }, + { id: "radio-service", key: "radio-service", label: "Funkgeräte-Service" }, + { id: "about", key: "about", label: "Über Uns" }, + { id: "contact", key: "contact", label: "Kontakt" }, + { id: "footer", key: "settings", label: "Footer" }, + { id: "global-seo", key: "settings", label: "SEO" }, + { id: "general-settings", key: "settings", label: "Allgemeine Einstellungen" }, +]; + +type Props = { + initialContent: ContentDocuments; + mediaFiles: MediaFile[]; +}; + +type ApiResponse = { + message?: string; + content?: ContentDocuments[K]; +}; + +function linesToText(lines: string[]) { + return lines.join("\n"); +} + +function textToLines(value: string) { + return value.split("\n").map((line) => line.trim()).filter(Boolean); +} + +function updateLink(link: LinkContent, field: keyof LinkContent, value: string): LinkContent { + return { ...link, [field]: value }; +} + +function TextInput({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) { + return ( + + ); +} + +function TextArea({ label, value, onChange, rows = 4 }: { label: string; value: string; rows?: number; onChange: (value: string) => void }) { + return ( +