feat(cms): add storage based website content management
This commit is contained in:
parent
7d5689cfa4
commit
12d31f5468
31 changed files with 1347 additions and 158 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
39
README.md
39
README.md
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
app/api/admin/content/route.ts
Normal file
27
app/api/admin/content/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
7
app/api/content/settings/route.ts
Normal file
7
app/api/content/settings/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
178
app/globals.css
178
app/globals.css
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
55
app/page.tsx
55
app/page.tsx
|
|
@ -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>
|
||||||
|
|
|
||||||
29
app/seo.ts
29
app/seo.ts
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
344
components/admin/ContentManager.tsx
Normal file
344
components/admin/ContentManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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
176
lib/content/defaults.ts
Normal 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
153
lib/content/service.ts
Normal 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
128
lib/content/types.ts
Normal 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;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
export const appVersion = "0.3.0";
|
export const appVersion = "0.4.0";
|
||||||
|
|
|
||||||
34
package-lock.json
generated
34
package-lock.json
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
0
storage/content/.gitkeep
Normal file
0
storage/content/backups/.gitkeep
Normal file
0
storage/content/backups/.gitkeep
Normal file
Loading…
Add table
Add a link
Reference in a new issue