feat(cms): add storage based website content management

This commit is contained in:
Schubert Ferenc 2026-07-04 00:58:33 +02:00
parent 7d5689cfa4
commit 12d31f5468
31 changed files with 1347 additions and 158 deletions

4
.gitignore vendored
View file

@ -11,8 +11,12 @@ data/site-settings.json
data/smtp-settings.json data/smtp-settings.json
storage/uploads/images/* storage/uploads/images/*
storage/config/* storage/config/*
storage/content/*
!storage/.gitkeep !storage/.gitkeep
!storage/config/.gitkeep !storage/config/.gitkeep
!storage/content/.gitkeep
!storage/content/backups/
!storage/content/backups/.gitkeep
!storage/uploads/.gitkeep !storage/uploads/.gitkeep
!storage/uploads/images/.gitkeep !storage/uploads/images/.gitkeep
.DS_Store .DS_Store

View file

@ -14,7 +14,7 @@ FROM node:22-alpine AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 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 DOCKER_ENV=true
ENV PORT=3010 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/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/scripts/runtime-start.js ./runtime-start.js 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 && chown -R nextjs:nodejs /app/data /app/storage /app/.next/cache
USER nextjs USER nextjs

View file

@ -8,12 +8,12 @@ Eigenständige öffentliche Firmenwebsite für Funktechnik Schubert. Dieses Proj
- TypeScript strict - TypeScript strict
- Serverseitige API-Routen für Kontakt und Reparaturannahme - Serverseitige API-Routen für Kontakt und Reparaturannahme
- Geschützter Admin-Bereich unter `/admin` - 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 - Docker
- Nginx/Reverse-Proxy-fähig - Nginx/Reverse-Proxy-fähig
- SEO Metadata, Sitemap und robots.txt - SEO Metadata, Sitemap und robots.txt
Version: `0.3.0` Version: `0.4.0`
## Seiten ## Seiten
@ -125,6 +125,9 @@ Für lokale Entwicklung kann `AUTH_COOKIE_SECURE=false` bleiben. Produktiv muss
- `POST /api/admin/settings` - `POST /api/admin/settings`
- `POST /api/admin/smtp` - `POST /api/admin/smtp`
- `GET/POST /api/admin/media` - `GET/POST /api/admin/media`
- `GET/PUT /api/admin/content`
- `GET /api/content/settings`
- `GET /api/media/[filename]`
- `GET /api/health` - `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. 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 - Dashboard mit Kennzahlen
- Kontaktanfragen verwalten - Kontaktanfragen verwalten
- Reparaturanfragen verwalten - Reparaturanfragen verwalten
- Website-Inhaltsverwaltung vorbereitet - Website-Inhalte pflegen und sofort veröffentlichen
- Firmendaten pflegen - Firmendaten pflegen
- SMTP-Konfiguration speichern und Testmail senden - SMTP-Konfiguration speichern und Testmail senden
- Medien hochladen - Medien hochladen
- SEO-Übersicht - SEO-Übersicht
- Systemü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 ## 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. 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 ## Healthcheck
```bash ```bash
@ -188,7 +217,7 @@ Antwort:
```json ```json
{ {
"status": "ok", "status": "ok",
"version": "0.3.0", "version": "0.4.0",
"storage": "ok", "storage": "ok",
"admin": "configured", "admin": "configured",
"smtp": "configured", "smtp": "configured",

View file

@ -3,6 +3,7 @@ import AdminShell from "@/components/admin/AdminShell";
import { requireAdminSession } from "@/lib/admin/auth"; import { requireAdminSession } from "@/lib/admin/auth";
import { adminConfigurationStatus, appVersion, checkStorage, configDirectory, dataDirectory, isDockerEnvironment, olympusStatus, uploadDirectory } from "@/lib/runtime/config"; import { adminConfigurationStatus, appVersion, checkStorage, configDirectory, dataDirectory, isDockerEnvironment, olympusStatus, uploadDirectory } from "@/lib/runtime/config";
import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config"; import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config";
import { getContentSystemStatus } from "@/lib/content/service";
export default async function AdminSystemPage() { export default async function AdminSystemPage() {
if (!await requireAdminSession()) redirect("/admin/login"); if (!await requireAdminSession()) redirect("/admin/login");
@ -13,7 +14,7 @@ export default async function AdminSystemPage() {
} catch { } catch {
storage = "error"; storage = "error";
} }
const smtpSettings = await getSmtpSettings(); const [smtpSettings, contentStatus] = await Promise.all([getSmtpSettings(), getContentSystemStatus()]);
const smtpConfigured = isSmtpConfigured(smtpSettings); const smtpConfigured = isSmtpConfigured(smtpSettings);
return ( return (
@ -37,6 +38,10 @@ export default async function AdminSystemPage() {
<div><dt>SMTP</dt><dd>{smtpConfigured ? "configured" : "missing"}</dd></div> <div><dt>SMTP</dt><dd>{smtpConfigured ? "configured" : "missing"}</dd></div>
<div><dt>Letzte SMTP-Testmail</dt><dd>{smtpSettings.lastTestAt ? `${new Date(smtpSettings.lastTestAt).toLocaleString("de-DE")} (${smtpSettings.lastTestStatus})` : "keine"}</dd></div> <div><dt>Letzte SMTP-Testmail</dt><dd>{smtpSettings.lastTestAt ? `${new Date(smtpSettings.lastTestAt).toLocaleString("de-DE")} (${smtpSettings.lastTestStatus})` : "keine"}</dd></div>
<div><dt>Letzter SMTP-Formularversand</dt><dd>{smtpSettings.lastDeliveryAt ? `${new Date(smtpSettings.lastDeliveryAt).toLocaleString("de-DE")} (${smtpSettings.lastDeliveryStatus})` : "keiner"}</dd></div> <div><dt>Letzter SMTP-Formularversand</dt><dd>{smtpSettings.lastDeliveryAt ? `${new Date(smtpSettings.lastDeliveryAt).toLocaleString("de-DE")} (${smtpSettings.lastDeliveryStatus})` : "keiner"}</dd></div>
<div><dt>Content Version</dt><dd>{contentStatus.version}</dd></div>
<div><dt>Letzte Content-Änderung</dt><dd>{contentStatus.lastModified ? new Date(contentStatus.lastModified).toLocaleString("de-DE") : "keine"}</dd></div>
<div><dt>Anzahl Seiten</dt><dd>{contentStatus.pageCount}</dd></div>
<div><dt>Anzahl Medien</dt><dd>{contentStatus.mediaCount}</dd></div>
</dl> </dl>
</section> </section>
<section className="admin-card"> <section className="admin-card">

View file

@ -1,18 +1,13 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import AdminShell from "@/components/admin/AdminShell"; import AdminShell from "@/components/admin/AdminShell";
import ContentManager from "@/components/admin/ContentManager";
import { requireAdminSession } from "@/lib/admin/auth"; import { requireAdminSession } from "@/lib/admin/auth";
import { listMediaFiles } from "@/lib/admin/media";
const editablePages = [ import { getAllContent } from "@/lib/content/service";
["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;
export default async function AdminWebsitePage() { export default async function AdminWebsitePage() {
if (!await requireAdminSession()) redirect("/admin/login"); if (!await requireAdminSession()) redirect("/admin/login");
const [content, mediaFiles] = await Promise.all([getAllContent(), listMediaFiles()]);
return ( return (
<AdminShell> <AdminShell>
@ -20,19 +15,7 @@ export default async function AdminWebsitePage() {
<p className="eyebrow">Website</p> <p className="eyebrow">Website</p>
<h1>Website-Inhalte</h1> <h1>Website-Inhalte</h1>
</div> </div>
<section className="admin-card"> <ContentManager initialContent={content} mediaFiles={mediaFiles} />
<h2>Editierbare Inhalte</h2>
<p className="admin-muted">Diese Foundation bereitet die Inhaltsverwaltung vor. Die öffentliche Website bleibt bis zur finalen Publishing-Funktion weiterhin quellcodebasiert.</p>
<div className="admin-detail-grid">
{editablePages.map(([title, description]) => (
<article key={title} className="admin-detail">
<h3>{title}</h3>
<p>{description}</p>
<button className="button light" type="button" disabled>Bearbeiten vorbereitet</button>
</article>
))}
</div>
</section>
</AdminShell> </AdminShell>
); );
} }

View file

@ -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 });
}

View file

@ -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 });
}

View file

@ -10,15 +10,15 @@ export default function DatenschutzPage() {
<PageHero eyebrow="Rechtliches" title="Datenschutz">Informationen zur Verarbeitung personenbezogener Daten.</PageHero> <PageHero eyebrow="Rechtliches" title="Datenschutz">Informationen zur Verarbeitung personenbezogener Daten.</PageHero>
<section className="section"> <section className="section">
<div className="container legal"> <div className="container legal">
<p className="notice">TODO: Rechtliche Angaben ergänzen</p> <p className="notice">Datenschutzhinweise müssen vor produktiver Veröffentlichung durch den Betreiber ergänzt und rechtlich geprüft werden.</p>
<h2>Verantwortlicher</h2> <h2>Verantwortlicher</h2>
<p>TODO: Rechtliche Angaben ergänzen</p> <p>Die Angaben zum Verantwortlichen werden durch den Betreiber gepflegt.</p>
<h2>Kontakt- und Reparaturanfragen</h2> <h2>Kontakt- und Reparaturanfragen</h2>
<p>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.</p> <p>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.</p>
<h2>Hosting und Server-Logs</h2> <h2>Hosting und Server-Logs</h2>
<p>TODO: Rechtliche Angaben ergänzen</p> <p>Informationen zum Hosting und zur Verarbeitung technischer Serverdaten werden durch den Betreiber gepflegt.</p>
<h2>Betroffenenrechte</h2> <h2>Betroffenenrechte</h2>
<p>TODO: Rechtliche Angaben ergänzen</p> <p>Hinweise zu Betroffenenrechten werden durch den Betreiber rechtlich geprüft und gepflegt.</p>
</div> </div>
</section> </section>
</> </>

View file

@ -1,33 +1,40 @@
import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import PageHero from "@/components/PageHero"; 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 ( return (
<> <>
<PageHero eyebrow="Funkgeräte-Service" title="Prüfung, Diagnose und Abgleich"> <PageHero eyebrow={content.eyebrow} title={content.title}>
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}
</PageHero> </PageHero>
<section className="section"> <section className="section">
<div className="container split"> <div className="container split">
<div> <div>
<h2>Typische Fehlerbilder</h2> <h2>{content.listTitle}</h2>
<ul className="list"> <ul className="list">
<li>Kein oder schwacher Empfang</li> {content.symptoms.map((symptom) => <li key={symptom}>{symptom}</li>)}
<li>Keine, leise oder verzerrte Modulation</li>
<li>Frequenzversatz, PLL-Rasten oder instabile Kanäle</li>
<li>Sendeprobleme, schwankende Leistung oder auffällige Erwärmung</li>
<li>Audio-/NF-Probleme, Displayfehler oder Aussetzer durch Bedienelemente</li>
<li>Unklare Vorarbeiten, fehlende Serviceunterlagen oder bereits geöffnete Geräte</li>
</ul> </ul>
</div> </div>
<div className="panel"> <div className="panel">
<h2>Messung und Einordnung</h2> <h2>{content.cta.text}</h2>
<p className="lead">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.</p> <p className="lead">{content.intro}</p>
<Link className="button" href="/reparatur">Reparatur anfragen</Link> <ul className="list">
{content.measurements.map((measurement) => <li key={measurement}>{measurement}</li>)}
</ul>
<p>{content.alignment}</p>
<p>{content.repair}</p>
<Link className="button" href={content.cta.primary.href}>{content.cta.primary.label}</Link>
</div> </div>
</div> </div>
</section> </section>

View file

@ -985,6 +985,171 @@ h3 {
overflow-wrap: anywhere; 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) { @media (max-width: 1080px) and (min-width: 861px) {
.brand-logo { .brand-logo {
width: 236px; width: 236px;
@ -1095,4 +1260,17 @@ h3 {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 4px; 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;
}
} }

View file

@ -10,13 +10,13 @@ export default function ImpressumPage() {
<PageHero eyebrow="Rechtliches" title="Impressum">Rechtliche Angaben für die öffentliche Firmenwebsite.</PageHero> <PageHero eyebrow="Rechtliches" title="Impressum">Rechtliche Angaben für die öffentliche Firmenwebsite.</PageHero>
<section className="section"> <section className="section">
<div className="container legal"> <div className="container legal">
<p className="notice">TODO: Rechtliche Angaben ergänzen</p> <p className="notice">Rechtliche Angaben müssen vor produktiver Veröffentlichung durch den Betreiber ergänzt und geprüft werden.</p>
<h2>Anbieterkennzeichnung</h2> <h2>Anbieterkennzeichnung</h2>
<p>TODO: Rechtliche Angaben ergänzen</p> <p>Die vollständige Anbieterkennzeichnung wird durch den Betreiber gepflegt.</p>
<h2>Kontakt</h2> <h2>Kontakt</h2>
<p>TODO: Rechtliche Angaben ergänzen</p> <p>Die rechtlich relevanten Kontaktdaten werden durch den Betreiber gepflegt.</p>
<h2>Umsatzsteuer / Registerangaben</h2> <h2>Umsatzsteuer / Registerangaben</h2>
<p>TODO: Rechtliche Angaben ergänzen</p> <p>Umsatzsteuer- und Registerangaben werden durch den Betreiber gepflegt, sofern sie erforderlich sind.</p>
</div> </div>
</section> </section>
</> </>

View file

@ -1,21 +1,40 @@
import type { Metadata } from "next";
import PageHero from "@/components/PageHero"; import PageHero from "@/components/PageHero";
import ContactForm from "@/components/ContactForm"; 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 ( return (
<> <>
<PageHero eyebrow="Kontakt" title="Kontakt aufnehmen"> <PageHero eyebrow={content.eyebrow} title={content.title}>
Beschreiben Sie kurz Ihr Anliegen. Für Reparaturen nutzen Sie idealerweise die strukturierte Reparaturannahme. {content.subtitle}
</PageHero> </PageHero>
<section className="section"> <section className="section">
<div className="container split"> <div className="container split">
<div> <div>
<h2>Direkt und technisch</h2> <h2>{content.cta.text}</h2>
<p className="lead">Je genauer Gerät, Anliegen und gewünschte Unterstützung beschrieben werden, desto gezielter kann die Rückmeldung erfolgen.</p> <p className="lead">{content.heroText}</p>
{contactDetails.length > 0 && (
<ul className="list">
{contactDetails.map((detail) => <li key={detail}>{detail}</li>)}
</ul>
)}
{content.googleMapsLink && <p><a className="button light" href={content.googleMapsLink}>Karte öffnen</a></p>}
</div> </div>
<div className="panel"> <div className="panel">
<ContactForm /> <ContactForm />

View file

@ -1,12 +1,13 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import SiteFrame from "@/components/SiteFrame"; import SiteFrame from "@/components/SiteFrame";
import "./globals.css"; import "./globals.css";
import { createMetadata } from "./seo"; import { getContent } from "@/lib/content/service";
import { createContentMetadata } from "./seo";
export const metadata: Metadata = createMetadata( export async function generateMetadata(): Promise<Metadata> {
"Funktechnik Schubert", const settings = await getContent("settings");
"Service, Reparatur, Diagnose und Abgleich von Funktechnik, CB-Funk, Amateurfunk und Kommunikationstechnik.", return createContentMetadata(settings.seo);
); }
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return ( return (

View file

@ -1,27 +1,29 @@
import type { Metadata } from "next";
import PageHero from "@/components/PageHero"; import PageHero from "@/components/PageHero";
import ServiceCard from "@/components/ServiceCard"; 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 = [ export async function generateMetadata() {
["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."], const content = await getContent("services");
["Fehlersuche & Reparatur", "Strukturierte Analyse bei fehlender Modulation, Frequenzabweichung, PLL-Problemen, Empfangs- oder Sendeproblemen, Audio-/NF-Fehlern und Anzeige- oder Displayfehlern."], return createContentMetadata(content.seo);
["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."], export default async function ServicesPage() {
] as const; 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 ( return (
<> <>
<PageHero eyebrow="Leistungen" title="Service für Funkgeräte und Kommunikationstechnik"> <PageHero eyebrow={content.eyebrow} title={content.title}>
Technische Unterstützung für Geräte, bei denen Diagnose, Messtechnik, Erfahrung und saubere Dokumentation zählen. {content.subtitle}
</PageHero> </PageHero>
<section className="section"> <section className="section">
<div className="container grid service-grid"> <div className="container grid service-grid">
{services.map(([title, text]) => <ServiceCard key={title} title={title}>{text}</ServiceCard>)} {services.map((service) => <ServiceCard key={service.title} title={service.title}>{service.description}</ServiceCard>)}
</div> </div>
</section> </section>
</> </>

View file

@ -1,26 +1,32 @@
import Link from "next/link"; import Link from "next/link";
import ServiceCard from "@/components/ServiceCard"; 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 ( return (
<> <>
<section className="hero"> <section className="hero">
<div className="container hero-content"> <div className="container hero-content">
<p className="eyebrow">Funktechnik, Messtechnik, Werkstattservice</p> <p className="eyebrow">{content.eyebrow}</p>
<h1>Funktechnik Schubert</h1> <h1>{content.title}</h1>
<p className="lead">Service, Reparatur und Diagnose für Funkgeräte, Messtechnik und Kommunikationstechnik.</p> <p className="lead">{content.subtitle}</p>
<p className="hero-copy">Von CB-Funk und Amateurfunk bis zur professionellen Funktechnik: Wir unterstützen bei Fehlersuche, Abgleich, Modulationsproblemen, Frequenzproblemen und technischer Dokumentation.</p> <p className="hero-copy">{content.heroText}</p>
<div className="hero-badges" aria-label="Technische Schwerpunkte"> <div className="hero-badges" aria-label="Technische Schwerpunkte">
<span>CB-Funk</span> {content.badges.map((badge) => <span key={badge}>{badge}</span>)}
<span>Amateurfunk</span>
<span>Messtechnik</span>
<span>Abgleich</span>
<span>Diagnose</span>
<span>Service Manuals</span>
</div> </div>
<div className="hero-actions"> <div className="hero-actions">
<Link className="button" href="/reparatur">Reparatur anfragen</Link> <Link className="button" href={content.cta.primary.href}>{content.cta.primary.label}</Link>
<Link className="button secondary" href="/leistungen">Leistungen ansehen</Link> <Link className="button secondary" href={content.cta.secondary.href}>{content.cta.secondary.label}</Link>
</div> </div>
</div> </div>
</section> </section>
@ -29,13 +35,11 @@ export default function HomePage() {
<div className="container"> <div className="container">
<div className="section-head"> <div className="section-head">
<p className="eyebrow">Leistungsbereiche</p> <p className="eyebrow">Leistungsbereiche</p>
<h2>Werkstattservice für Funktechnik und Messtechnik</h2> <h2>{content.cta.text}</h2>
<p className="lead">Von der ersten Fehlerbeschreibung bis zur dokumentierten Prüfung: Funktechnik Schubert verbindet technische Erfahrung, geeignete Messmittel und strukturierte Kommunikation.</p> <p className="lead">{content.intro}</p>
</div> </div>
<div className="grid three"> <div className="grid three">
<ServiceCard title="Funkgeräte-Service">Sichtprüfung, Funktionsprüfung und technische Beurteilung für CB-Funk, Amateurfunk, Betriebsfunk sowie ältere und moderne Geräte.</ServiceCard> {content.features.map((feature) => <ServiceCard key={feature.title} title={feature.title}>{feature.text}</ServiceCard>)}
<ServiceCard title="Fehlersuche & Reparatur">Systematische Diagnose bei Empfangsproblemen, Sendeproblemen, PLL-Fehlern, Frequenzabweichungen und Audio-/NF-Störungen.</ServiceCard>
<ServiceCard title="Abgleich & Messtechnik">Frequenzabgleich, Hub- und Modulationsprüfung, Sendeleistung, Empfindlichkeit und RF-Messung mit geeigneter Messtechnik.</ServiceCard>
</div> </div>
</div> </div>
</section> </section>
@ -43,20 +47,17 @@ export default function HomePage() {
<section className="section dark"> <section className="section dark">
<div className="container split"> <div className="container split">
<div> <div>
<p className="eyebrow">Persönlicher Support</p> <p className="eyebrow">{content.promise.eyebrow}</p>
<h2>Direkte technische Einschätzung statt anonymer Abwicklung</h2> <h2>{content.promise.title}</h2>
<p className="lead">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.</p> <p className="lead">{content.promise.text}</p>
<div className="actions"> <div className="actions">
<Link className="button" href="/reparatur">Reparaturformular öffnen</Link> <Link className="button" href={content.promise.button.href}>{content.promise.button.label}</Link>
</div> </div>
</div> </div>
<div className="card"> <div className="card">
<h3>Warum Funktechnik Schubert?</h3> <h3>{content.promise.title}</h3>
<ul className="list"> <ul className="list">
<li>Technischer Fokus auf Funkgeräte und Kommunikationstechnik</li> {content.promise.bullets.map((item) => <li key={item}>{item}</li>)}
<li>Nachvollziehbare Diagnose mit Blick auf Messwerte und Fehlerbild</li>
<li>Sauberer Umgang mit Service Manuals, Schaltplänen und Abgleichanleitungen</li>
<li>Klare Rückmeldung zu Zustand, Aufwand und sinnvollen nächsten Schritten</li>
</ul> </ul>
</div> </div>
</div> </div>

View file

@ -1,4 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import type { SeoContent } from "@/lib/content/types";
const siteName = "Funktechnik Schubert"; const siteName = "Funktechnik Schubert";
const defaultTitle = "Funktechnik Schubert | Funkgeräte-Service & Messtechnik"; 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 }; export { defaultTitle, siteName, siteUrl };

View file

@ -1,25 +1,34 @@
import type { Metadata } from "next";
import PageHero from "@/components/PageHero"; 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 ( return (
<> <>
<PageHero eyebrow="Über uns" title="Technik verstehen, Fehler nachvollziehbar lösen"> <PageHero eyebrow={content.eyebrow} title={content.title}>
Funktechnik Schubert richtet sich an Kunden, die bei Funkgeräten und Kommunikationstechnik eine persönliche, technische Einschätzung suchen. {content.subtitle}
</PageHero> </PageHero>
<section className="section"> <section className="section">
<div className="container grid two"> <div className="container grid two">
<article className="card"> {blocks.map((block) => (
<h2>Arbeitsweise</h2> <article key={block.title} className="card">
<p>Im Mittelpunkt stehen saubere Diagnose, transparente Kommunikation und der respektvolle Umgang mit bestehenden Geräten und Serviceunterlagen.</p> <h2>{block.title}</h2>
</article> <p>{block.text}</p>
<article className="card"> </article>
<h2>Fokus</h2> ))}
<p>Der Schwerpunkt liegt auf Funktechnik, Elektronikservice, Abgleichfragen und technischer Unterstützung rund um Kommunikationstechnik.</p>
</article>
</div> </div>
</section> </section>
</> </>

View file

@ -1,33 +1,82 @@
"use client";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react";
import { appVersion } from "@/lib/runtime/version"; 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() { 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 ( return (
<footer className="footer"> <footer className="footer">
<div className="container footer-grid"> <div className="container footer-grid">
<div> <div>
<Image <Image
src="/funktechnik_schubert_logo.jpg" src={settings.logoUrl}
alt="Funktechnik Schubert" alt={settings.siteName}
width={1672} width={1672}
height={941} height={941}
className="footer-logo" className="footer-logo"
/> />
<p>Funktechnik Schubert Service und technische Unterstützung für Funkgeräte, Messtechnik und Kommunikationstechnik.</p> <p>{settings.footerShortText}</p>
<p className="copyright">© Funktechnik Schubert · v{appVersion}</p> <p className="copyright">{settings.copyright} · v{appVersion}</p>
</div> </div>
<div> <div>
<h3>Website</h3> <h3>Website</h3>
<p><Link href="/leistungen">Leistungen</Link></p> {settings.footerLinks.map((link) => <p key={`${link.href}-${link.label}`}><Link href={link.href}>{link.label}</Link></p>)}
<p><Link href="/funkgeraete-service">Funkgeräte-Service</Link></p>
<p><Link href="/reparatur">Reparatur</Link></p>
<p><Link href="/kontakt">Kontakt</Link></p>
</div> </div>
<div> <div>
<h3>Rechtliches</h3> <h3>Rechtliches</h3>
<p><Link href="/impressum">Impressum</Link></p> {settings.legalLinks.map((link) => <p key={`${link.href}-${link.label}`}><Link href={link.href}>{link.label}</Link></p>)}
<p><Link href="/datenschutz">Datenschutz</Link></p>
</div> </div>
</div> </div>
</footer> </footer>

View file

@ -3,7 +3,8 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useState } from "react"; import { useEffect, useState } from "react";
import type { SettingsContent } from "@/lib/content/types";
const navigation = [ const navigation = [
["Start", "/"], ["Start", "/"],
@ -14,17 +15,49 @@ const navigation = [
["Kontakt", "/kontakt"], ["Kontakt", "/kontakt"],
] as const; ] as const;
const fallbackSettings = {
siteName: "Funktechnik Schubert",
logoUrl: "/funktechnik_schubert_logo.jpg",
headerCta: { label: "Reparatur anfragen", href: "/reparatur" },
};
export default function Header() { export default function Header() {
const pathname = usePathname(); const pathname = usePathname();
const [menuOpen, setMenuOpen] = useState(false); 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 ( return (
<header className="site-header"> <header className="site-header">
<div className="container header-inner"> <div className="container header-inner">
<Link href="/" className="brand" aria-label="Funktechnik Schubert Startseite" onClick={() => setMenuOpen(false)}> <Link href="/" className="brand" aria-label={`${settings.siteName} Startseite`} onClick={() => setMenuOpen(false)}>
<Image <Image
src="/funktechnik_schubert_logo.jpg" src={settings.logoUrl}
alt="Funktechnik Schubert" alt={settings.siteName}
width={1672} width={1672}
height={941} height={941}
priority priority
@ -47,7 +80,7 @@ export default function Header() {
</Link> </Link>
))} ))}
</nav> </nav>
<Link className="header-cta" href="/reparatur">Reparatur anfragen</Link> <Link className="header-cta" href={settings.headerCta.href}>{settings.headerCta.label}</Link>
</div> </div>
</header> </header>
); );

View file

@ -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<K extends ContentKey> = {
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 (
<label>
{label}
<input value={value} onChange={(event) => onChange(event.target.value)} />
</label>
);
}
function TextArea({ label, value, onChange, rows = 4 }: { label: string; value: string; rows?: number; onChange: (value: string) => void }) {
return (
<label>
{label}
<textarea rows={rows} value={value} onChange={(event) => onChange(event.target.value)} />
</label>
);
}
function ImageSelect({ label, value, mediaFiles, onChange }: { label: string; value: string; mediaFiles: MediaFile[]; onChange: (value: string) => void }) {
const images = mediaFiles.filter((file) => file.isImage);
return (
<label>
{label}
<select value={value} onChange={(event) => onChange(event.target.value)}>
<option value="/funktechnik_schubert_logo.jpg">Standard-Logo</option>
<option value="/workbench-signal.svg">Standard-Hintergrund</option>
{images.map((file) => <option key={file.name} value={file.url}>{file.name}</option>)}
</select>
</label>
);
}
function SectionTitle({ children }: { children: string }) {
return <h3 className="content-editor-heading">{children}</h3>;
}
function BlocksEditor({ blocks, onChange }: { blocks: TextBlockContent[]; onChange: (blocks: TextBlockContent[]) => void }) {
function update(index: number, field: keyof TextBlockContent, value: string) {
onChange(blocks.map((block, current) => current === index ? { ...block, [field]: value } : block));
}
return (
<div className="content-repeaters">
{blocks.map((block, index) => (
<div key={index} className="content-repeater">
<TextInput label="Titel" value={block.title} onChange={(value) => update(index, "title", value)} />
<TextArea label="Text" value={block.text} onChange={(value) => update(index, "text", value)} />
<button className="button light" type="button" onClick={() => onChange(blocks.filter((_, current) => current !== index))}>Entfernen</button>
</div>
))}
<button className="button light" type="button" onClick={() => onChange([...blocks, { title: "", text: "" }])}>Block hinzufügen</button>
</div>
);
}
function PageFields({ page, mediaFiles, onChange }: { page: PageContent; mediaFiles: MediaFile[]; onChange: (page: PageContent) => void }) {
return (
<>
<SectionTitle>Hero</SectionTitle>
<div className="content-field-grid">
<TextInput label="Titel" value={page.title} onChange={(value) => onChange({ ...page, title: value })} />
<TextInput label="Untertitel" value={page.subtitle} onChange={(value) => onChange({ ...page, subtitle: value })} />
<TextInput label="Eyebrow" value={page.eyebrow} onChange={(value) => onChange({ ...page, eyebrow: value })} />
<TextArea label="Hero Text" value={page.heroText} onChange={(value) => onChange({ ...page, heroText: value })} />
</div>
<SectionTitle>Inhalt</SectionTitle>
<TextArea label="Einleitung" value={page.intro} onChange={(value) => onChange({ ...page, intro: value })} />
<TextArea label="Listenpunkte, eine Zeile pro Eintrag" value={linesToText(page.list)} onChange={(value) => onChange({ ...page, list: textToLines(value) })} />
<BlocksEditor blocks={page.paragraphs} onChange={(paragraphs) => onChange({ ...page, paragraphs })} />
<SectionTitle>Call-To-Action</SectionTitle>
<div className="content-field-grid">
<TextInput label="CTA Text" value={page.cta.text} onChange={(value) => onChange({ ...page, cta: { ...page.cta, text: value } })} />
<TextInput label="Button 1 Text" value={page.cta.primary.label} onChange={(value) => onChange({ ...page, cta: { ...page.cta, primary: updateLink(page.cta.primary, "label", value) } })} />
<TextInput label="Button 1 Link" value={page.cta.primary.href} onChange={(value) => onChange({ ...page, cta: { ...page.cta, primary: updateLink(page.cta.primary, "href", value) } })} />
<TextInput label="Button 2 Text" value={page.cta.secondary.label} onChange={(value) => onChange({ ...page, cta: { ...page.cta, secondary: updateLink(page.cta.secondary, "label", value) } })} />
<TextInput label="Button 2 Link" value={page.cta.secondary.href} onChange={(value) => onChange({ ...page, cta: { ...page.cta, secondary: updateLink(page.cta.secondary, "href", value) } })} />
</div>
<SectionTitle>SEO</SectionTitle>
<div className="content-field-grid">
<TextInput label="Meta Title" value={page.seo.metaTitle} onChange={(value) => onChange({ ...page, seo: { ...page.seo, metaTitle: value } })} />
<TextInput label="Meta Description" value={page.seo.metaDescription} onChange={(value) => onChange({ ...page, seo: { ...page.seo, metaDescription: value } })} />
<TextInput label="Keywords" value={page.seo.keywords} onChange={(value) => onChange({ ...page, seo: { ...page.seo, keywords: value } })} />
<TextInput label="OpenGraph Titel" value={page.seo.openGraphTitle} onChange={(value) => onChange({ ...page, seo: { ...page.seo, openGraphTitle: value } })} />
<TextInput label="OpenGraph Beschreibung" value={page.seo.openGraphDescription} onChange={(value) => onChange({ ...page, seo: { ...page.seo, openGraphDescription: value } })} />
<ImageSelect label="Social Image" value={page.seo.socialImage} mediaFiles={mediaFiles} onChange={(value) => onChange({ ...page, seo: { ...page.seo, socialImage: value } })} />
<TextInput label="Canonical URL" value={page.seo.canonicalUrl} onChange={(value) => onChange({ ...page, seo: { ...page.seo, canonicalUrl: value } })} />
</div>
</>
);
}
function ServicesEditor({ services, onChange }: { services: ServiceContent[]; onChange: (services: ServiceContent[]) => void }) {
function update(index: number, field: keyof ServiceContent, value: string | number | boolean) {
onChange(services.map((service, current) => current === index ? { ...service, [field]: value } : service));
}
return (
<div className="content-repeaters">
{services.map((service, index) => (
<div key={index} className="content-repeater">
<div className="content-field-grid">
<TextInput label="Titel" value={service.title} onChange={(value) => update(index, "title", value)} />
<TextInput label="Icon" value={service.icon} onChange={(value) => update(index, "icon", value)} />
<label>
Sortierung
<input type="number" value={service.sortOrder} onChange={(event) => update(index, "sortOrder", Number(event.target.value))} />
</label>
<label className="admin-checkbox">
<input type="checkbox" checked={service.visible} onChange={(event) => update(index, "visible", event.target.checked)} />
Einblenden
</label>
</div>
<TextArea label="Beschreibung" value={service.description} onChange={(value) => update(index, "description", value)} />
<button className="button light" type="button" onClick={() => onChange(services.filter((_, current) => current !== index))}>Leistung entfernen</button>
</div>
))}
<button className="button light" type="button" onClick={() => onChange([...services, { title: "", description: "", icon: "radio", sortOrder: services.length * 10 + 10, visible: true }])}>Leistung hinzufügen</button>
</div>
);
}
export default function ContentManager({ initialContent, mediaFiles }: Props) {
const [content, setContent] = useState(initialContent);
const [activeTab, setActiveTab] = useState<ContentTabId>("home");
const [viewport, setViewport] = useState<Viewport>("desktop");
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
const active = tabs.find((tab) => tab.id === activeTab)?.key ?? "home";
const current = content[active];
const activeLabel = useMemo(() => tabs.find((tab) => tab.id === activeTab)?.label ?? active, [active, activeTab]);
function update<K extends ContentKey>(key: K, value: ContentDocuments[K]) {
setContent((previous) => ({ ...previous, [key]: value }));
}
async function save() {
setPending(true);
setMessage("");
setError("");
try {
const response = await fetch("/api/admin/content", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: active, content: current }),
});
const result = await response.json() as ApiResponse<typeof active>;
if (!response.ok || !result.content) throw new Error(result.message ?? "Inhalt konnte nicht gespeichert werden.");
update(active, result.content);
setMessage(result.message ?? "Inhalt gespeichert.");
} catch (err) {
setError(err instanceof Error ? err.message : "Inhalt konnte nicht gespeichert werden.");
} finally {
setPending(false);
}
}
const pagePreview = "title" in current ? current : content.home;
return (
<div className="content-manager">
{message && <p className="success admin-message">{message}</p>}
{error && <p className="error admin-message">{error}</p>}
<section className="admin-card content-editor-layout">
<aside className="content-tabs" aria-label="Website-Inhalte">
{tabs.map((tab, index) => (
<button key={`${tab.label}-${index}`} type="button" aria-current={activeTab === tab.id ? "page" : undefined} onClick={() => setActiveTab(tab.id)}>
{tab.label}
</button>
))}
</aside>
<div className="content-editor">
<div className="content-editor-top">
<div>
<p className="eyebrow">Editor</p>
<h2>{activeLabel}</h2>
</div>
<button className="button" type="button" onClick={save} disabled={pending}>{pending ? "Speichert..." : "Speichern"}</button>
</div>
{"title" in current && (
<PageFields page={current} mediaFiles={mediaFiles} onChange={(value) => update(active, value as ContentDocuments[typeof active])} />
)}
{active === "home" && (
<>
<SectionTitle>Homepage Builder</SectionTitle>
<TextArea label="Badges, eine Zeile pro Eintrag" value={linesToText(content.home.badges)} onChange={(value) => update("home", { ...content.home, badges: textToLines(value) })} />
<h4>3 Featureboxen</h4>
<BlocksEditor blocks={content.home.features} onChange={(features) => update("home", { ...content.home, features })} />
<h4>3 Leistungsboxen</h4>
<BlocksEditor blocks={content.home.services} onChange={(services) => update("home", { ...content.home, services })} />
<h4>Kundenversprechen</h4>
<TextInput label="Headline" value={content.home.promise.title} onChange={(value) => update("home", { ...content.home, promise: { ...content.home.promise, title: value } })} />
<TextArea label="Text" value={content.home.promise.text} onChange={(value) => update("home", { ...content.home, promise: { ...content.home.promise, text: value } })} />
<TextArea label="Punkte, eine Zeile pro Eintrag" value={linesToText(content.home.promise.bullets)} onChange={(value) => update("home", { ...content.home, promise: { ...content.home.promise, bullets: textToLines(value) } })} />
<TextArea label="Footer-Text" value={content.home.footerText} onChange={(value) => update("home", { ...content.home, footerText: value })} />
</>
)}
{active === "services" && (
<>
<SectionTitle>Leistungen</SectionTitle>
<ServicesEditor services={content.services.services} onChange={(services) => update("services", { ...content.services, services })} />
</>
)}
{active === "radio-service" && (
<>
<SectionTitle>Funkgeräte-Service</SectionTitle>
<TextArea label="Marken" value={linesToText(content["radio-service"].brands)} onChange={(value) => update("radio-service", { ...content["radio-service"], brands: textToLines(value) })} />
<TextArea label="Fehlerbilder" value={linesToText(content["radio-service"].symptoms)} onChange={(value) => update("radio-service", { ...content["radio-service"], symptoms: textToLines(value) })} />
<TextArea label="Ablauf" value={linesToText(content["radio-service"].workflow)} onChange={(value) => update("radio-service", { ...content["radio-service"], workflow: textToLines(value) })} />
<TextArea label="Messmöglichkeiten" value={linesToText(content["radio-service"].measurements)} onChange={(value) => update("radio-service", { ...content["radio-service"], measurements: textToLines(value) })} />
<TextArea label="Abgleich" value={content["radio-service"].alignment} onChange={(value) => update("radio-service", { ...content["radio-service"], alignment: value })} />
<TextArea label="Reparatur" value={content["radio-service"].repair} onChange={(value) => update("radio-service", { ...content["radio-service"], repair: value })} />
</>
)}
{active === "about" && (
<>
<SectionTitle>Über Uns</SectionTitle>
<TextArea label="Firmenbeschreibung" value={content.about.companyDescription} onChange={(value) => update("about", { ...content.about, companyDescription: value })} />
<TextArea label="Werkstattbeschreibung" value={content.about.workshopDescription} onChange={(value) => update("about", { ...content.about, workshopDescription: value })} />
<TextArea label="Philosophie" value={content.about.philosophy} onChange={(value) => update("about", { ...content.about, philosophy: value })} />
</>
)}
{active === "contact" && (
<>
<SectionTitle>Kontaktinformationen</SectionTitle>
<div className="content-field-grid">
<TextInput label="Telefon" value={content.contact.phone} onChange={(value) => update("contact", { ...content.contact, phone: value })} />
<TextInput label="E-Mail" value={content.contact.email} onChange={(value) => update("contact", { ...content.contact, email: value })} />
<TextInput label="Adresse" value={content.contact.address} onChange={(value) => update("contact", { ...content.contact, address: value })} />
<TextInput label="Öffnungszeiten" value={content.contact.openingHours} onChange={(value) => update("contact", { ...content.contact, openingHours: value })} />
<TextInput label="Google Maps Link" value={content.contact.googleMapsLink} onChange={(value) => update("contact", { ...content.contact, googleMapsLink: value })} />
</div>
</>
)}
{activeTab === "footer" && (
<>
<SectionTitle>Footer und Einstellungen</SectionTitle>
<div className="content-field-grid">
<ImageSelect label="Logo" value={content.settings.logoUrl} mediaFiles={mediaFiles} onChange={(value) => update("settings", { ...content.settings, logoUrl: value })} />
<TextInput label="Copyright" value={content.settings.copyright} onChange={(value) => update("settings", { ...content.settings, copyright: value })} />
</div>
<TextArea label="Kurztext" value={content.settings.footerShortText} onChange={(value) => update("settings", { ...content.settings, footerShortText: value })} />
</>
)}
{activeTab === "global-seo" && (
<>
<SectionTitle>Globale SEO</SectionTitle>
<div className="content-field-grid">
<TextInput label="Meta Title" value={content.settings.seo.metaTitle} onChange={(value) => update("settings", { ...content.settings, seo: { ...content.settings.seo, metaTitle: value } })} />
<TextInput label="Meta Description" value={content.settings.seo.metaDescription} onChange={(value) => update("settings", { ...content.settings, seo: { ...content.settings.seo, metaDescription: value } })} />
<ImageSelect label="Social Image" value={content.settings.seo.socialImage} mediaFiles={mediaFiles} onChange={(value) => update("settings", { ...content.settings, seo: { ...content.settings.seo, socialImage: value } })} />
</div>
</>
)}
{activeTab === "general-settings" && (
<>
<SectionTitle>Allgemeine Einstellungen</SectionTitle>
<div className="content-field-grid">
<TextInput label="Site Name" value={content.settings.siteName} onChange={(value) => update("settings", { ...content.settings, siteName: value })} />
<TextInput label="Header CTA Text" value={content.settings.headerCta.label} onChange={(value) => update("settings", { ...content.settings, headerCta: updateLink(content.settings.headerCta, "label", value) })} />
<TextInput label="Header CTA Link" value={content.settings.headerCta.href} onChange={(value) => update("settings", { ...content.settings, headerCta: updateLink(content.settings.headerCta, "href", value) })} />
</div>
</>
)}
</div>
</section>
<section className="admin-card content-preview-card">
<div className="content-preview-toolbar">
<h2>Live Preview</h2>
<div>
{(["desktop", "tablet", "mobile"] as const).map((item) => (
<button key={item} type="button" aria-current={viewport === item ? "true" : undefined} onClick={() => setViewport(item)}>{item}</button>
))}
</div>
</div>
<div className={`content-preview ${viewport}`}>
<p className="eyebrow">{pagePreview.eyebrow}</p>
<h1>{pagePreview.title}</h1>
<p className="lead">{pagePreview.subtitle}</p>
<p>{pagePreview.heroText}</p>
<div className="hero-actions">
<span className="button">{pagePreview.cta.primary.label}</span>
<span className="button light">{pagePreview.cta.secondary.label}</span>
</div>
</div>
</section>
</div>
);
}

View file

@ -8,7 +8,7 @@ services:
- "3010:3010" - "3010:3010"
environment: environment:
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://127.0.0.1:3010} NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://127.0.0.1:3010}
NEXT_PUBLIC_APP_VERSION: "0.3.0" NEXT_PUBLIC_APP_VERSION: "0.4.0"
ADMIN_EMAIL: ${ADMIN_EMAIL:-} ADMIN_EMAIL: ${ADMIN_EMAIL:-}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET:-} ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET:-}

176
lib/content/defaults.ts Normal file
View file

@ -0,0 +1,176 @@
import type { ContentDocuments, SeoContent } from "./types";
const now = "2026-01-01T00:00:00.000Z";
function seo(title: string, description: string, path: string): SeoContent {
return {
metaTitle: title,
metaDescription: description,
keywords: "Funktechnik Schubert, Funkgeräte Reparatur, CB Funk Service, Amateurfunk Service, Funkgeräte Abgleich, Messtechnik",
openGraphTitle: title,
openGraphDescription: description,
socialImage: "/funktechnik_schubert_logo.jpg",
canonicalUrl: path,
};
}
export const defaultContent: ContentDocuments = {
home: {
eyebrow: "Funktechnik, Messtechnik, Werkstattservice",
title: "Funktechnik Schubert",
subtitle: "Service, Reparatur und Diagnose für Funkgeräte, Messtechnik und Kommunikationstechnik.",
heroText: "Von CB-Funk und Amateurfunk bis zur professionellen Funktechnik: Wir unterstützen bei Fehlersuche, Abgleich, Modulationsproblemen, Frequenzproblemen und technischer Dokumentation.",
intro: "Von der ersten Fehlerbeschreibung bis zur dokumentierten Prüfung: Funktechnik Schubert verbindet technische Erfahrung, geeignete Messmittel und strukturierte Kommunikation.",
paragraphs: [],
cta: {
text: "Direkte technische Einschätzung statt anonymer Abwicklung",
primary: { label: "Reparatur anfragen", href: "/reparatur" },
secondary: { label: "Leistungen ansehen", href: "/leistungen" },
},
listTitle: "Technische Schwerpunkte",
list: ["CB-Funk", "Amateurfunk", "Messtechnik", "Abgleich", "Diagnose", "Service Manuals"],
badges: ["CB-Funk", "Amateurfunk", "Messtechnik", "Abgleich", "Diagnose", "Service Manuals"],
features: [
{ title: "Funkgeräte-Service", text: "Sichtprüfung, Funktionsprüfung und technische Beurteilung für CB-Funk, Amateurfunk, Betriebsfunk sowie ältere und moderne Geräte." },
{ title: "Fehlersuche & Reparatur", text: "Systematische Diagnose bei Empfangsproblemen, Sendeproblemen, PLL-Fehlern, Frequenzabweichungen und Audio-/NF-Störungen." },
{ title: "Abgleich & Messtechnik", text: "Frequenzabgleich, Hub- und Modulationsprüfung, Sendeleistung, Empfindlichkeit und RF-Messung mit geeigneter Messtechnik." },
],
services: [
{ title: "Funkgeräte-Service", text: "Prüfung und technische Einschätzung für CB-Funk, Amateurfunk und Kommunikationstechnik." },
{ title: "Messtechnik", text: "Messungen mit geeigneter Werkstattpraxis und nachvollziehbarer Dokumentation." },
{ title: "Serviceunterlagen", text: "Einordnung von Schaltplänen, Service Manuals und Abgleichhinweisen." },
],
promise: {
eyebrow: "Persönlicher Support",
title: "Direkte technische Einschätzung statt anonymer Abwicklung",
text: "Viele Fehlerbilder lassen sich bereits mit einer klaren Beschreibung und den richtigen Gerätedaten eingrenzen.",
bullets: [
"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",
],
button: { label: "Reparaturformular öffnen", href: "/reparatur" },
},
footerText: "Service und technische Unterstützung für Funkgeräte, Messtechnik und Kommunikationstechnik.",
faq: [],
seo: seo("Funktechnik Schubert", "Service, Reparatur, Diagnose und Abgleich von Funktechnik, CB-Funk, Amateurfunk und Kommunikationstechnik.", "/"),
updatedAt: now,
},
services: {
eyebrow: "Leistungen",
title: "Service für Funkgeräte und Kommunikationstechnik",
subtitle: "Technische Unterstützung für Geräte, bei denen Diagnose, Messtechnik, Erfahrung und saubere Dokumentation zählen.",
heroText: "Leistungen für private und professionelle Funktechnik mit klarem Fokus auf Diagnose, Abgleich und Reparatur.",
intro: "Die Leistungen können im Adminbereich sortiert, erweitert und ein- oder ausgeblendet werden.",
paragraphs: [],
cta: { text: "Reparaturfall vorbereiten", primary: { label: "Reparatur anfragen", href: "/reparatur" }, secondary: { label: "Kontakt aufnehmen", href: "/kontakt" } },
listTitle: "Leistungen",
list: [],
services: [
{ title: "Funkgeräte-Service", description: "Service für CB-Funk, Amateurfunk, Betriebsfunk sowie ältere und moderne Funkgeräte.", icon: "radio", sortOrder: 10, visible: true },
{ title: "Fehlersuche & Reparatur", description: "Strukturierte Analyse bei Modulationsproblemen, Frequenzabweichung, PLL-Problemen und Empfangs- oder Sendeproblemen.", icon: "tools", sortOrder: 20, visible: true },
{ title: "Abgleich & Prüfung", description: "Frequenzabgleich, Hub- und Modulationsprüfung, Sendeleistung, Empfindlichkeit und Funktionsprüfung.", icon: "meter", sortOrder: 30, visible: true },
{ title: "Messtechnik & Diagnose", description: "Systematische Fehlersuche mit Oszilloskop, Frequenzzähler, Signalgenerator, RF-Messung und Modulationsmessung.", icon: "scope", sortOrder: 40, visible: true },
{ title: "Serviceunterlagen", description: "Einordnung von Schaltplänen, Service Manuals, Abgleichanleitungen und Reparaturhinweisen.", icon: "manual", sortOrder: 50, visible: true },
],
faq: [],
seo: seo("Leistungen", "Funkgeräte-Service, Diagnose, Abgleich und Dokumentation für Kommunikationstechnik.", "/leistungen"),
updatedAt: now,
},
"radio-service": {
eyebrow: "Funkgeräte-Service",
title: "Prüfung, Diagnose und Abgleich",
subtitle: "Funkgeräte zeigen Fehler oft erst im Zusammenspiel von Empfang, Sendeteil, Versorgung, Antennenanpassung, Bedienung und Abgleich.",
heroText: "Der Service betrachtet diese Zusammenhänge strukturiert und dokumentiert die technischen Schritte nachvollziehbar.",
intro: "Eine gute Reparatur beginnt mit vollständigen Angaben zu Gerät, Fehlerbild und Vorgeschichte.",
paragraphs: [],
cta: { text: "Reparaturprozess starten", primary: { label: "Reparatur anfragen", href: "/reparatur" }, secondary: { label: "Kontakt", href: "/kontakt" } },
listTitle: "Typische Fehlerbilder",
list: [],
brands: ["CB-Funk", "Amateurfunk", "Betriebsfunk"],
symptoms: [
"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",
],
workflow: ["Anfrage", "Technische Sichtung", "Messung", "Abstimmung", "Reparatur oder Rückgabe"],
measurements: ["Frequenz", "Sendeleistung", "Hub", "Modulation", "Empfindlichkeit"],
alignment: "Abgleich erfolgt anhand technischer Unterlagen und sinnvoller Messpunkte.",
repair: "Reparaturen werden vor Durchführung technisch eingeordnet und abgestimmt.",
faq: [],
seo: seo("Funkgeräte-Service", "Werkstattservice für CB-Funk, Amateurfunk und Funkgeräte-Abgleich.", "/funkgeraete-service"),
updatedAt: now,
},
about: {
eyebrow: "Über uns",
title: "Technik verstehen, Fehler nachvollziehbar lösen",
subtitle: "Funktechnik Schubert richtet sich an Kunden, die bei Funkgeräten und Kommunikationstechnik eine persönliche, technische Einschätzung suchen.",
heroText: "Im Mittelpunkt stehen saubere Diagnose, transparente Kommunikation und respektvoller Umgang mit bestehenden Geräten.",
intro: "Technischer Service lebt von Genauigkeit, Erfahrung und klarer Rückmeldung.",
paragraphs: [
{ title: "Arbeitsweise", text: "Im Mittelpunkt stehen saubere Diagnose, transparente Kommunikation und der respektvolle Umgang mit bestehenden Geräten und Serviceunterlagen." },
{ title: "Fokus", text: "Der Schwerpunkt liegt auf Funktechnik, Elektronikservice, Abgleichfragen und technischer Unterstützung rund um Kommunikationstechnik." },
],
cta: { text: "Mehr zur Werkstatt", primary: { label: "Kontakt aufnehmen", href: "/kontakt" }, secondary: { label: "Leistungen", href: "/leistungen" } },
listTitle: "Grundsätze",
list: ["Nachvollziehbare technische Einschätzung", "Klare Kommunikation", "Sorgfältiger Umgang mit Geräten"],
companyDescription: "Funktechnik Schubert bietet technischen Service für Funkgeräte, Messtechnik und Kommunikationstechnik.",
workshopDescription: "Die Werkstatt arbeitet strukturiert mit Messmitteln, Dokumentation und technischer Prüfung.",
philosophy: "Reparatur und Service sollen nachvollziehbar, ehrlich und technisch sauber bleiben.",
faq: [],
seo: seo("Über uns", "Funktechnik Schubert steht für technischen Service und klare Kommunikation.", "/ueber-uns"),
updatedAt: now,
},
contact: {
eyebrow: "Kontakt",
title: "Kontakt aufnehmen",
subtitle: "Beschreiben Sie kurz Ihr Anliegen. Für Reparaturen nutzen Sie idealerweise die strukturierte Reparaturannahme.",
heroText: "Je genauer Gerät, Anliegen und gewünschte Unterstützung beschrieben werden, desto gezielter kann die Rückmeldung erfolgen.",
intro: "Direkt und technisch: Nutzen Sie das Kontaktformular oder die Reparaturannahme.",
paragraphs: [],
cta: { text: "Anfrage senden", primary: { label: "Reparatur anfragen", href: "/reparatur" }, secondary: { label: "Leistungen", href: "/leistungen" } },
listTitle: "Kontaktinformationen",
list: [],
phone: "",
email: "",
address: "",
openingHours: "",
googleMapsLink: "",
socialMedia: [],
faq: [],
seo: seo("Kontakt", "Kontakt zu Funktechnik Schubert aufnehmen.", "/kontakt"),
updatedAt: now,
},
settings: {
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: seo("Funktechnik Schubert", "Service, Reparatur, Diagnose und Abgleich von Funktechnik, CB-Funk, Amateurfunk und Kommunikationstechnik.", "/"),
updatedAt: now,
},
};
export const contentFileNames = {
home: "home.json",
services: "services.json",
"radio-service": "radio-service.json",
about: "about.json",
contact: "contact.json",
settings: "settings.json",
} as const;

153
lib/content/service.ts Normal file
View file

@ -0,0 +1,153 @@
import { existsSync } from "fs";
import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from "fs/promises";
import path from "path";
import { storageDirectory } from "@/lib/runtime/config";
import { listMediaFiles } from "@/lib/admin/media";
import { contentFileNames, defaultContent } from "./defaults";
import type { ContentDocuments, ContentKey, ContentSummary } from "./types";
export const contentDirectory = path.join(storageDirectory, "content");
export const contentBackupDirectory = path.join(contentDirectory, "backups");
export const contentVersion = "0.4.0";
const contentKeys = Object.keys(contentFileNames) as ContentKey[];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function sanitizeString(value: string) {
return value
.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, "")
.replace(/javascript:/gi, "")
.replace(/[<>]/g, "")
.trim();
}
function sanitizeValue(value: unknown): unknown {
if (typeof value === "string") return sanitizeString(value);
if (Array.isArray(value)) return value.map(sanitizeValue);
if (!isRecord(value)) return value;
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, sanitizeValue(entry)]));
}
function mergeContent<T>(defaults: T, incoming: unknown): T {
if (Array.isArray(defaults)) return Array.isArray(incoming) ? sanitizeValue(incoming) as T : defaults;
if (!isRecord(defaults)) return sanitizeValue(incoming ?? defaults) as T;
if (!isRecord(incoming)) return defaults;
const merged: Record<string, unknown> = { ...defaults };
for (const [key, value] of Object.entries(defaults)) {
merged[key] = mergeContent(value, incoming[key]);
}
return sanitizeValue(merged) as T;
}
function filePathFor(key: ContentKey) {
return path.join(contentDirectory, contentFileNames[key]);
}
function backupStamp() {
const date = new Date();
const pad = (value: number) => String(value).padStart(2, "0");
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`;
}
async function pruneBackups(key: ContentKey) {
const prefix = `${key}.`;
const entries = await readdir(contentBackupDirectory).catch(() => []);
const backups = await Promise.all(entries
.filter((entry) => entry.startsWith(prefix) && entry.endsWith(".json"))
.map(async (entry) => {
const fullPath = path.join(contentBackupDirectory, entry);
const fileStat = await stat(fullPath);
return { entry, fullPath, modified: fileStat.mtimeMs };
}));
const obsolete = backups.sort((left, right) => right.modified - left.modified).slice(20);
await Promise.all(obsolete.map((entry) => unlink(entry.fullPath).catch(() => undefined)));
}
async function ensureContentDocument<K extends ContentKey>(key: K) {
await mkdir(contentDirectory, { recursive: true });
await mkdir(contentBackupDirectory, { recursive: true });
const target = filePathFor(key);
if (!existsSync(target)) {
await writeFile(target, `${JSON.stringify(defaultContent[key], null, 2)}\n`, "utf8");
}
}
export async function ensureContentStorage() {
await mkdir(contentDirectory, { recursive: true });
await mkdir(contentBackupDirectory, { recursive: true });
await Promise.all(contentKeys.map((key) => ensureContentDocument(key)));
}
export async function getContent<K extends ContentKey>(key: K): Promise<ContentDocuments[K]> {
await ensureContentDocument(key);
try {
const raw = await readFile(filePathFor(key), "utf8");
const parsed = JSON.parse(raw) as unknown;
return mergeContent(defaultContent[key], parsed);
} catch {
return defaultContent[key];
}
}
export async function getAllContent(): Promise<ContentDocuments> {
const entries = await Promise.all(contentKeys.map(async (key) => [key, await getContent(key)] as const));
return Object.fromEntries(entries) as ContentDocuments;
}
export async function saveContent<K extends ContentKey>(key: K, content: ContentDocuments[K]) {
await ensureContentDocument(key);
const current = filePathFor(key);
const backup = path.join(contentBackupDirectory, `${key}.${backupStamp()}.json`);
if (existsSync(current)) await rename(current, backup);
const next = mergeContent(defaultContent[key], {
...content,
updatedAt: new Date().toISOString(),
});
await writeFile(current, `${JSON.stringify(next, null, 2)}\n`, "utf8");
await pruneBackups(key);
return next;
}
export async function getContentSummary(): Promise<ContentSummary> {
await ensureContentStorage();
const documents = await Promise.all(contentKeys.map(async (key) => {
const content = await getContent(key);
return {
key,
fileName: contentFileNames[key],
title: "title" in content ? content.title : content.siteName,
updatedAt: content.updatedAt,
};
}));
const lastModified = documents
.map((document) => document.updatedAt)
.filter(Boolean)
.sort()
.at(-1) ?? null;
return {
version: contentVersion,
lastModified,
pageCount: documents.length,
documents,
};
}
export async function getContentSystemStatus() {
const [summary, media] = await Promise.all([getContentSummary(), listMediaFiles()]);
return {
...summary,
mediaCount: media.length,
};
}

128
lib/content/types.ts Normal file
View file

@ -0,0 +1,128 @@
export type SeoContent = {
metaTitle: string;
metaDescription: string;
keywords: string;
openGraphTitle: string;
openGraphDescription: string;
socialImage: string;
canonicalUrl: string;
};
export type LinkContent = {
label: string;
href: string;
};
export type TextBlockContent = {
title: string;
text: string;
};
export type FaqContent = {
question: string;
answer: string;
};
export type ServiceContent = {
title: string;
description: string;
icon: string;
sortOrder: number;
visible: boolean;
};
export type PageContent = {
eyebrow: string;
title: string;
subtitle: string;
heroText: string;
intro: string;
paragraphs: TextBlockContent[];
cta: {
text: string;
primary: LinkContent;
secondary: LinkContent;
};
listTitle: string;
list: string[];
faq: FaqContent[];
seo: SeoContent;
updatedAt: string;
};
export type HomeContent = PageContent & {
badges: string[];
features: TextBlockContent[];
services: TextBlockContent[];
promise: {
eyebrow: string;
title: string;
text: string;
bullets: string[];
button: LinkContent;
};
footerText: string;
};
export type ServicesContent = PageContent & {
services: ServiceContent[];
};
export type RadioServiceContent = PageContent & {
brands: string[];
symptoms: string[];
workflow: string[];
measurements: string[];
alignment: string;
repair: string;
};
export type AboutContent = PageContent & {
companyDescription: string;
workshopDescription: string;
philosophy: string;
};
export type ContactContent = PageContent & {
phone: string;
email: string;
address: string;
openingHours: string;
googleMapsLink: string;
socialMedia: LinkContent[];
};
export type SettingsContent = {
siteName: string;
logoUrl: string;
footerShortText: string;
copyright: string;
footerLinks: LinkContent[];
legalLinks: LinkContent[];
headerCta: LinkContent;
seo: SeoContent;
updatedAt: string;
};
export type ContentDocuments = {
home: HomeContent;
services: ServicesContent;
"radio-service": RadioServiceContent;
about: AboutContent;
contact: ContactContent;
settings: SettingsContent;
};
export type ContentKey = keyof ContentDocuments;
export type ContentSummary = {
version: string;
lastModified: string | null;
pageCount: number;
documents: Array<{
key: ContentKey;
fileName: string;
title: string;
updatedAt: string;
}>;
};

View file

@ -6,6 +6,8 @@ import { appVersion } from "./version";
export const dataDirectory = path.join(process.cwd(), "data"); export const dataDirectory = path.join(process.cwd(), "data");
export const storageDirectory = path.join(process.cwd(), "storage"); export const storageDirectory = path.join(process.cwd(), "storage");
export const configDirectory = path.join(storageDirectory, "config"); export const configDirectory = path.join(storageDirectory, "config");
export const contentDirectory = path.join(storageDirectory, "content");
export const contentBackupDirectory = path.join(contentDirectory, "backups");
export const uploadDirectory = path.join(storageDirectory, "uploads", "images"); export const uploadDirectory = path.join(storageDirectory, "uploads", "images");
export const legacyPublicUploadDirectory = path.join(process.cwd(), "public", "uploads", "images"); export const legacyPublicUploadDirectory = path.join(process.cwd(), "public", "uploads", "images");
@ -20,6 +22,7 @@ export function isDockerEnvironment() {
export async function ensureRuntimeDirectories() { export async function ensureRuntimeDirectories() {
await mkdir(dataDirectory, { recursive: true }); await mkdir(dataDirectory, { recursive: true });
await mkdir(configDirectory, { recursive: true }); await mkdir(configDirectory, { recursive: true });
await mkdir(contentBackupDirectory, { recursive: true });
await mkdir(uploadDirectory, { recursive: true }); await mkdir(uploadDirectory, { recursive: true });
await migrateLegacyUploads(); await migrateLegacyUploads();
} }

View file

@ -1 +1 @@
export const appVersion = "0.3.0"; export const appVersion = "0.4.0";

34
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "funktechnik-schubert-website", "name": "funktechnik-schubert-website",
"version": "0.3.0", "version": "0.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "funktechnik-schubert-website", "name": "funktechnik-schubert-website",
"version": "0.3.0", "version": "0.4.0",
"dependencies": { "dependencies": {
"@types/nodemailer": "^8.0.1", "@types/nodemailer": "^8.0.1",
"next": "16.2.10", "next": "16.2.10",
@ -529,7 +529,7 @@
"darwin" "darwin"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.4.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -551,7 +551,7 @@
"darwin" "darwin"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.4.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -760,7 +760,7 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.4.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -785,7 +785,7 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.4.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -810,7 +810,7 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.4.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -835,7 +835,7 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.4.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -860,7 +860,7 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.4.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -885,7 +885,7 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.4.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -910,7 +910,7 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.4.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -935,7 +935,7 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.4.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -957,7 +957,7 @@
"@emnapi/runtime": "^1.7.0" "@emnapi/runtime": "^1.7.0"
}, },
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.4.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -976,7 +976,7 @@
"win32" "win32"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.4.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -995,7 +995,7 @@
"win32" "win32"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.4.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -1014,7 +1014,7 @@
"win32" "win32"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.4.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -5313,7 +5313,7 @@
"semver": "^7.7.3" "semver": "^7.7.3"
}, },
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.4.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"

View file

@ -1,6 +1,6 @@
{ {
"name": "funktechnik-schubert-website", "name": "funktechnik-schubert-website",
"version": "0.3.0", "version": "0.4.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3010", "dev": "next dev -p 3010",

View file

@ -6,6 +6,8 @@ const requiredEnv = ["ADMIN_EMAIL", "ADMIN_PASSWORD", "ADMIN_SESSION_SECRET"];
const requiredDirs = [ const requiredDirs = [
path.join(process.cwd(), "data"), path.join(process.cwd(), "data"),
path.join(process.cwd(), "storage", "config"), path.join(process.cwd(), "storage", "config"),
path.join(process.cwd(), "storage", "content"),
path.join(process.cwd(), "storage", "content", "backups"),
path.join(process.cwd(), "storage", "uploads", "images"), path.join(process.cwd(), "storage", "uploads", "images"),
path.join(process.cwd(), ".next", "cache"), path.join(process.cwd(), ".next", "cache"),
]; ];
@ -54,5 +56,5 @@ try {
fail(`Start abgebrochen: Runtime-Verzeichnis ist nicht beschreibbar. Details: ${error instanceof Error ? error.message : "unbekannter Fehler"}`); fail(`Start abgebrochen: Runtime-Verzeichnis ist nicht beschreibbar. Details: ${error instanceof Error ? error.message : "unbekannter Fehler"}`);
} }
process.stdout.write(`[funktechnik-website] Runtime checks ok. Starting version ${process.env.NEXT_PUBLIC_APP_VERSION ?? "0.3.0"}.\n`); process.stdout.write(`[funktechnik-website] Runtime checks ok. Starting version ${process.env.NEXT_PUBLIC_APP_VERSION ?? "0.4.0"}.\n`);
require("./server.js"); require("./server.js");

0
storage/content/.gitkeep Normal file
View file

View file