feat(cms): improve admin content editing
This commit is contained in:
parent
12d31f5468
commit
a284bbc188
26 changed files with 362 additions and 147 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -12,6 +12,7 @@ data/smtp-settings.json
|
||||||
storage/uploads/images/*
|
storage/uploads/images/*
|
||||||
storage/config/*
|
storage/config/*
|
||||||
storage/content/*
|
storage/content/*
|
||||||
|
storage/content/backups/*
|
||||||
!storage/.gitkeep
|
!storage/.gitkeep
|
||||||
!storage/config/.gitkeep
|
!storage/config/.gitkeep
|
||||||
!storage/content/.gitkeep
|
!storage/content/.gitkeep
|
||||||
|
|
|
||||||
|
|
@ -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.4.0
|
ENV NEXT_PUBLIC_APP_VERSION=0.4.1
|
||||||
ENV DOCKER_ENV=true
|
ENV DOCKER_ENV=true
|
||||||
ENV PORT=3010
|
ENV PORT=3010
|
||||||
|
|
||||||
|
|
|
||||||
16
README.md
16
README.md
|
|
@ -13,7 +13,7 @@ Eigenständige öffentliche Firmenwebsite für Funktechnik Schubert. Dieses Proj
|
||||||
- Nginx/Reverse-Proxy-fähig
|
- Nginx/Reverse-Proxy-fähig
|
||||||
- SEO Metadata, Sitemap und robots.txt
|
- SEO Metadata, Sitemap und robots.txt
|
||||||
|
|
||||||
Version: `0.4.0`
|
Version: `0.4.1`
|
||||||
|
|
||||||
## Seiten
|
## Seiten
|
||||||
|
|
||||||
|
|
@ -180,6 +180,7 @@ Website-Inhalte liegen unter `storage/content/` und werden nicht committed:
|
||||||
storage/content/home.json
|
storage/content/home.json
|
||||||
storage/content/services.json
|
storage/content/services.json
|
||||||
storage/content/radio-service.json
|
storage/content/radio-service.json
|
||||||
|
storage/content/repair.json
|
||||||
storage/content/about.json
|
storage/content/about.json
|
||||||
storage/content/contact.json
|
storage/content/contact.json
|
||||||
storage/content/settings.json
|
storage/content/settings.json
|
||||||
|
|
@ -193,6 +194,7 @@ Bearbeitbar sind aktuell:
|
||||||
- Startseite mit Hero, Call-to-Actions, Featureboxen, Leistungsboxen und Kundenversprechen
|
- Startseite mit Hero, Call-to-Actions, Featureboxen, Leistungsboxen und Kundenversprechen
|
||||||
- Leistungen mit beliebig vielen sortierbaren und ein-/ausblendbaren Einträgen
|
- Leistungen mit beliebig vielen sortierbaren und ein-/ausblendbaren Einträgen
|
||||||
- Funkgeräte-Service mit Einleitung, Marken, Fehlerbildern, Ablauf, Messmöglichkeiten, Abgleich und Reparaturtexten
|
- Funkgeräte-Service mit Einleitung, Marken, Fehlerbildern, Ablauf, Messmöglichkeiten, Abgleich und Reparaturtexten
|
||||||
|
- Reparaturannahme mit SEO-Daten und Seitentexten
|
||||||
- Über Uns mit Firmenbeschreibung, Werkstattbeschreibung und Philosophie
|
- Über Uns mit Firmenbeschreibung, Werkstattbeschreibung und Philosophie
|
||||||
- Kontakt mit Kontaktinformationen, Öffnungszeiten, Maps-Link und Seitentexten
|
- Kontakt mit Kontaktinformationen, Öffnungszeiten, Maps-Link und Seitentexten
|
||||||
- Footer, Logo, Copyright, Header-CTA und globale SEO-Einstellungen
|
- Footer, Logo, Copyright, Header-CTA und globale SEO-Einstellungen
|
||||||
|
|
@ -200,6 +202,16 @@ Bearbeitbar sind aktuell:
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
## Roadmap Kundenportal
|
||||||
|
|
||||||
|
Ein Kundenportal ist vorbereitet, aber noch nicht öffentlich implementiert. Für spätere Ausbaustufen sind folgende Routen vorgesehen:
|
||||||
|
|
||||||
|
- `/status`: öffentliche Status-Einstiegsseite
|
||||||
|
- `/reparatur/status`: Statusabfrage für Reparaturanfragen
|
||||||
|
- `/portal/login`: geschützter Kundenlogin
|
||||||
|
|
||||||
|
Diese Routen sind bewusst noch nicht angelegt. Die spätere Umsetzung soll serverseitig erfolgen, ohne Tokens im Browser-JavaScript zu speichern und ohne bestehende Admin- oder CMS-Funktionen zu umgehen.
|
||||||
|
|
||||||
## Healthcheck
|
## Healthcheck
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -217,7 +229,7 @@ Antwort:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"storage": "ok",
|
"storage": "ok",
|
||||||
"admin": "configured",
|
"admin": "configured",
|
||||||
"smtp": "configured",
|
"smtp": "configured",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import AdminShell from "@/components/admin/AdminShell";
|
import AdminShell from "@/components/admin/AdminShell";
|
||||||
import { requireAdminSession } from "@/lib/admin/auth";
|
import { requireAdminSession } from "@/lib/admin/auth";
|
||||||
|
import { listMediaFiles } from "@/lib/admin/media";
|
||||||
import { getContactInquiries, getRepairInquiries } from "@/lib/admin/store";
|
import { getContactInquiries, getRepairInquiries } from "@/lib/admin/store";
|
||||||
|
import { getContentSummary } from "@/lib/content/service";
|
||||||
import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config";
|
import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config";
|
||||||
|
import { appVersion, checkStorage } from "@/lib/runtime/config";
|
||||||
|
|
||||||
function countNew<T extends { status: string }>(items: T[]) {
|
function countNew<T extends { status: string }>(items: T[]) {
|
||||||
return items.filter((item) => item.status === "new").length;
|
return items.filter((item) => item.status === "new").length;
|
||||||
|
|
@ -10,8 +13,23 @@ function countNew<T extends { status: string }>(items: T[]) {
|
||||||
|
|
||||||
export default async function AdminDashboardPage() {
|
export default async function AdminDashboardPage() {
|
||||||
if (!await requireAdminSession()) redirect("/admin/login");
|
if (!await requireAdminSession()) redirect("/admin/login");
|
||||||
const [contacts, repairs, smtpSettings] = await Promise.all([getContactInquiries(), getRepairInquiries(), getSmtpSettings()]);
|
let healthStatus = "OK";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await checkStorage();
|
||||||
|
} catch {
|
||||||
|
healthStatus = "Fehler";
|
||||||
|
}
|
||||||
|
|
||||||
|
const [contacts, repairs, smtpSettings, mediaFiles, contentSummary] = await Promise.all([
|
||||||
|
getContactInquiries(),
|
||||||
|
getRepairInquiries(),
|
||||||
|
getSmtpSettings(),
|
||||||
|
listMediaFiles(),
|
||||||
|
getContentSummary(),
|
||||||
|
]);
|
||||||
const latest = [...contacts, ...repairs].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 6);
|
const latest = [...contacts, ...repairs].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 6);
|
||||||
|
const contentModified = contentSummary.lastModified ? new Date(contentSummary.lastModified).toLocaleString("de-DE") : "keine Änderung";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminShell>
|
<AdminShell>
|
||||||
|
|
@ -20,10 +38,13 @@ export default async function AdminDashboardPage() {
|
||||||
<h1>Dashboard</h1>
|
<h1>Dashboard</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-kpis">
|
<div className="admin-kpis">
|
||||||
<div className="admin-kpi"><span>Kontaktanfragen</span><strong>{countNew(contacts)}</strong><small>neu</small></div>
|
<div className="admin-kpi"><span>Kontaktanfragen</span><strong>{contacts.length}</strong><small>{countNew(contacts)} neu</small></div>
|
||||||
<div className="admin-kpi"><span>Reparaturanfragen</span><strong>{countNew(repairs)}</strong><small>neu</small></div>
|
<div className="admin-kpi"><span>Reparaturanfragen</span><strong>{repairs.length}</strong><small>{countNew(repairs)} neu</small></div>
|
||||||
<div className="admin-kpi"><span>Website</span><strong>Online</strong><small>OK</small></div>
|
<div className="admin-kpi"><span>Medien</span><strong>{mediaFiles.length}</strong><small>im Storage</small></div>
|
||||||
|
<div className="admin-kpi"><span>Content</span><strong>{contentSummary.pageCount}</strong><small>{contentModified}</small></div>
|
||||||
<div className="admin-kpi"><span>SMTP</span><strong>{isSmtpConfigured(smtpSettings) ? "OK" : "Fehlt"}</strong><small>{smtpSettings.lastTestStatus ?? "kein Test"}</small></div>
|
<div className="admin-kpi"><span>SMTP</span><strong>{isSmtpConfigured(smtpSettings) ? "OK" : "Fehlt"}</strong><small>{smtpSettings.lastTestStatus ?? "kein Test"}</small></div>
|
||||||
|
<div className="admin-kpi"><span>Version</span><strong>v{appVersion}</strong><small>Website</small></div>
|
||||||
|
<div className="admin-kpi"><span>Health</span><strong>{healthStatus}</strong><small>Storage und Runtime</small></div>
|
||||||
</div>
|
</div>
|
||||||
<section className="admin-card">
|
<section className="admin-card">
|
||||||
<h2>Letzte Anfragen</h2>
|
<h2>Letzte Anfragen</h2>
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,22 @@ export default async function AdminSystemPage() {
|
||||||
<li>SMTP-Versand ist serverseitig aktiv, sofern eine vollständige Konfiguration gespeichert ist.</li>
|
<li>SMTP-Versand ist serverseitig aktiv, sofern eine vollständige Konfiguration gespeichert ist.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
<section className="admin-card">
|
||||||
|
<h2>Letzte Content-Backups</h2>
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead><tr><th>Seite</th><th>Datum</th><th>Dateiname</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{contentStatus.backups.map((backup) => (
|
||||||
|
<tr key={backup.fileName}>
|
||||||
|
<td>{backup.page}</td>
|
||||||
|
<td>{new Date(backup.createdAt).toLocaleString("de-DE")}</td>
|
||||||
|
<td>{backup.fileName}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{contentStatus.backups.length === 0 && <tr><td colSpan={3}>Noch keine Content-Backups vorhanden.</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
</AdminShell>
|
</AdminShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { requireAdminSession } from "@/lib/admin/auth";
|
||||||
import { getAllContent, saveContent } from "@/lib/content/service";
|
import { getAllContent, saveContent } from "@/lib/content/service";
|
||||||
import type { ContentDocuments, ContentKey } from "@/lib/content/types";
|
import type { ContentDocuments, ContentKey } from "@/lib/content/types";
|
||||||
|
|
||||||
const keys: ContentKey[] = ["home", "services", "radio-service", "about", "contact", "settings"];
|
const keys: ContentKey[] = ["home", "services", "radio-service", "repair", "about", "contact", "settings"];
|
||||||
|
|
||||||
function isContentKey(value: unknown): value is ContentKey {
|
function isContentKey(value: unknown): value is ContentKey {
|
||||||
return typeof value === "string" && keys.includes(value as ContentKey);
|
return typeof value === "string" && keys.includes(value as ContentKey);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import PageHero from "@/components/PageHero";
|
import PageHero from "@/components/PageHero";
|
||||||
|
import { defaultContent } from "@/lib/content/defaults";
|
||||||
import { getContent } from "@/lib/content/service";
|
import { getContent } from "@/lib/content/service";
|
||||||
import { createContentMetadata } from "../seo";
|
import { createContentMetadata } from "../seo";
|
||||||
|
|
||||||
|
|
@ -7,7 +8,7 @@ export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const content = await getContent("radio-service");
|
const content = await getContent("radio-service");
|
||||||
return createContentMetadata(content.seo);
|
return createContentMetadata(content.seo, defaultContent["radio-service"].seo);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function RadioServicePage() {
|
export default async function RadioServicePage() {
|
||||||
|
|
|
||||||
|
|
@ -630,7 +630,7 @@ h3 {
|
||||||
|
|
||||||
.admin-kpis {
|
.admin-kpis {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
|
|
@ -1039,6 +1039,11 @@ h3 {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content-save-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.content-editor-heading {
|
.content-editor-heading {
|
||||||
margin: 14px 0 0;
|
margin: 14px 0 0;
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
|
|
@ -1094,6 +1099,27 @@ h3 {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content-image-picker {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-image-preview {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(140px, 240px) auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-image-preview img {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
.content-preview-card {
|
.content-preview-card {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
@ -1110,6 +1136,10 @@ h3 {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content-preview-toolbar .eyebrow {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.content-preview-toolbar div {
|
.content-preview-toolbar div {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
@ -1273,4 +1303,16 @@ h3 {
|
||||||
.content-field-grid {
|
.content-field-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content-editor-top,
|
||||||
|
.content-preview-toolbar,
|
||||||
|
.content-image-preview {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-editor-top,
|
||||||
|
.content-preview-toolbar {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import PageHero from "@/components/PageHero";
|
import PageHero from "@/components/PageHero";
|
||||||
import ContactForm from "@/components/ContactForm";
|
import ContactForm from "@/components/ContactForm";
|
||||||
|
import { defaultContent } from "@/lib/content/defaults";
|
||||||
import { getContent } from "@/lib/content/service";
|
import { getContent } from "@/lib/content/service";
|
||||||
import { createContentMetadata } from "../seo";
|
import { createContentMetadata } from "../seo";
|
||||||
|
|
||||||
|
|
@ -7,7 +8,7 @@ export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const content = await getContent("contact");
|
const content = await getContent("contact");
|
||||||
return createContentMetadata(content.seo);
|
return createContentMetadata(content.seo, defaultContent.contact.seo);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ContactPage() {
|
export default async function ContactPage() {
|
||||||
|
|
|
||||||
|
|
@ -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 { defaultContent } from "@/lib/content/defaults";
|
||||||
import { getContent } from "@/lib/content/service";
|
import { getContent } from "@/lib/content/service";
|
||||||
import { createContentMetadata } from "./seo";
|
import { createContentMetadata } from "./seo";
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const settings = await getContent("settings");
|
const settings = await getContent("settings");
|
||||||
return createContentMetadata(settings.seo);
|
return createContentMetadata(settings.seo, defaultContent.settings.seo);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import PageHero from "@/components/PageHero";
|
import PageHero from "@/components/PageHero";
|
||||||
import ServiceCard from "@/components/ServiceCard";
|
import ServiceCard from "@/components/ServiceCard";
|
||||||
|
import { defaultContent } from "@/lib/content/defaults";
|
||||||
import { getContent } from "@/lib/content/service";
|
import { getContent } from "@/lib/content/service";
|
||||||
import { createContentMetadata } from "../seo";
|
import { createContentMetadata } from "../seo";
|
||||||
|
|
||||||
|
|
@ -7,7 +8,7 @@ export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const content = await getContent("services");
|
const content = await getContent("services");
|
||||||
return createContentMetadata(content.seo);
|
return createContentMetadata(content.seo, defaultContent.services.seo);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ServicesPage() {
|
export default async function ServicesPage() {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import ServiceCard from "@/components/ServiceCard";
|
import ServiceCard from "@/components/ServiceCard";
|
||||||
|
import { defaultContent } from "@/lib/content/defaults";
|
||||||
import { getContent } from "@/lib/content/service";
|
import { getContent } from "@/lib/content/service";
|
||||||
import { createContentMetadata } from "./seo";
|
import { createContentMetadata } from "./seo";
|
||||||
|
|
||||||
|
|
@ -7,7 +8,7 @@ export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const content = await getContent("home");
|
const content = await getContent("home");
|
||||||
return createContentMetadata(content.seo);
|
return createContentMetadata(content.seo, defaultContent.home.seo);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
|
|
@ -15,7 +16,10 @@ export default async function HomePage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="hero">
|
<section
|
||||||
|
className="hero"
|
||||||
|
style={{ backgroundImage: `linear-gradient(90deg, rgba(6, 24, 52, 0.96), rgba(8, 42, 96, 0.78), rgba(8, 42, 96, 0.58)), url("${content.heroImage || "/workbench-signal.svg"}"), linear-gradient(135deg, #07172d, #0b3778)` }}
|
||||||
|
>
|
||||||
<div className="container hero-content">
|
<div className="container hero-content">
|
||||||
<p className="eyebrow">{content.eyebrow}</p>
|
<p className="eyebrow">{content.eyebrow}</p>
|
||||||
<h1>{content.title}</h1>
|
<h1>{content.title}</h1>
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,32 @@
|
||||||
import type { Metadata } from "next";
|
|
||||||
import PageHero from "@/components/PageHero";
|
import PageHero from "@/components/PageHero";
|
||||||
import RepairForm from "@/components/RepairForm";
|
import RepairForm from "@/components/RepairForm";
|
||||||
import { createMetadata } from "../seo";
|
import { defaultContent } from "@/lib/content/defaults";
|
||||||
|
import { getContent } from "@/lib/content/service";
|
||||||
|
import { createContentMetadata } from "../seo";
|
||||||
|
|
||||||
export const metadata: Metadata = createMetadata("Reparaturannahme", "Reparaturanfrage für Funkgeräte strukturiert vorbereiten.", "/reparatur");
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const content = await getContent("repair");
|
||||||
|
return createContentMetadata(content.seo, defaultContent.repair.seo);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RepairPage() {
|
||||||
|
const content = await getContent("repair");
|
||||||
|
const paragraph = content.paragraphs[0];
|
||||||
|
|
||||||
export default function RepairPage() {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero eyebrow="Reparaturannahme" title="Reparatur anfragen">
|
<PageHero eyebrow={content.eyebrow} title={content.title}>
|
||||||
Beschreiben Sie Gerät, Fehlerbild, Zubehör und bisherige Vorarbeiten. Die Anfrage wird strukturiert erfasst und für eine spätere technische Bearbeitung vorbereitet.
|
{content.subtitle}
|
||||||
</PageHero>
|
</PageHero>
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<div className="container split">
|
<div className="container split">
|
||||||
<div>
|
<div>
|
||||||
<h2>Vor der Einsendung</h2>
|
<h2>{paragraph?.title || content.cta.text}</h2>
|
||||||
<p className="lead">Bitte senden Sie Geräte erst nach Rückmeldung ein. Vollständige Gerätedaten und eine präzise Fehlerbeschreibung helfen, den Aufwand besser einzuschätzen.</p>
|
<p className="lead">{paragraph?.text || content.intro}</p>
|
||||||
<ul className="list">
|
<ul className="list">
|
||||||
<li>Hersteller, Modell, Geräteart und Seriennummer bereithalten, soweit vorhanden</li>
|
{content.list.map((item) => <li key={item}>{item}</li>)}
|
||||||
<li>Fehler möglichst konkret beschreiben: Empfang, Sendung, Modulation, Frequenz, Anzeige oder Versorgung</li>
|
|
||||||
<li>Zubehör wie Netzteil, Mikrofon, Antennenadapter oder Kabel angeben</li>
|
|
||||||
<li>Bereits geöffnete Geräte und durchgeführte Arbeiten ehrlich nennen</li>
|
|
||||||
<li>Fotos können später nach Rückmeldung ergänzt werden</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
|
|
|
||||||
22
app/seo.ts
22
app/seo.ts
|
|
@ -60,28 +60,32 @@ export function createMetadata(title: string, description: string, path = "/"):
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createContentMetadata(seo: SeoContent): Metadata {
|
export function createContentMetadata(seo: SeoContent, fallbackSeo?: SeoContent): Metadata {
|
||||||
const path = seo.canonicalUrl || "/";
|
const resolvedSeo = {
|
||||||
|
...fallbackSeo,
|
||||||
|
...Object.fromEntries(Object.entries(seo).filter(([, value]) => value.trim() !== "")),
|
||||||
|
} as SeoContent;
|
||||||
|
const path = resolvedSeo.canonicalUrl || "/";
|
||||||
const url = new URL(path, siteUrl).toString();
|
const url = new URL(path, siteUrl).toString();
|
||||||
const title = seo.metaTitle || siteName;
|
const title = resolvedSeo.metaTitle || siteName;
|
||||||
const description = seo.metaDescription || defaultTitle;
|
const description = resolvedSeo.metaDescription || defaultTitle;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...createMetadata(title, description, path),
|
...createMetadata(title, description, path),
|
||||||
keywords: seo.keywords ? seo.keywords.split(",").map((keyword) => keyword.trim()).filter(Boolean) : keywords,
|
keywords: resolvedSeo.keywords ? resolvedSeo.keywords.split(",").map((keyword) => keyword.trim()).filter(Boolean) : keywords,
|
||||||
openGraph: {
|
openGraph: {
|
||||||
type: "website",
|
type: "website",
|
||||||
locale: "de_DE",
|
locale: "de_DE",
|
||||||
url,
|
url,
|
||||||
siteName,
|
siteName,
|
||||||
title: seo.openGraphTitle || title,
|
title: resolvedSeo.openGraphTitle || title,
|
||||||
description: seo.openGraphDescription || description,
|
description: resolvedSeo.openGraphDescription || description,
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
url: seo.socialImage || "/funktechnik_schubert_logo.jpg",
|
url: resolvedSeo.socialImage || "/funktechnik_schubert_logo.jpg",
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 630,
|
height: 630,
|
||||||
alt: seo.openGraphTitle || title,
|
alt: resolvedSeo.openGraphTitle || title,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import PageHero from "@/components/PageHero";
|
import PageHero from "@/components/PageHero";
|
||||||
|
import { defaultContent } from "@/lib/content/defaults";
|
||||||
import { getContent } from "@/lib/content/service";
|
import { getContent } from "@/lib/content/service";
|
||||||
import { createContentMetadata } from "../seo";
|
import { createContentMetadata } from "../seo";
|
||||||
|
|
||||||
|
|
@ -6,7 +7,7 @@ export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const content = await getContent("about");
|
const content = await getContent("about");
|
||||||
return createContentMetadata(content.seo);
|
return createContentMetadata(content.seo, defaultContent.about.seo);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AboutPage() {
|
export default async function AboutPage() {
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ export default function Footer() {
|
||||||
<div className="container footer-grid">
|
<div className="container footer-grid">
|
||||||
<div>
|
<div>
|
||||||
<Image
|
<Image
|
||||||
src={settings.logoUrl}
|
src={settings.logoUrl || fallbackSettings.logoUrl}
|
||||||
alt={settings.siteName}
|
alt={settings.siteName}
|
||||||
width={1672}
|
width={1672}
|
||||||
height={941}
|
height={941}
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ export default function Header() {
|
||||||
if (active && response.ok && result.settings) {
|
if (active && response.ok && result.settings) {
|
||||||
setSettings({
|
setSettings({
|
||||||
siteName: result.settings.siteName,
|
siteName: result.settings.siteName,
|
||||||
logoUrl: result.settings.logoUrl,
|
logoUrl: result.settings.logoUrl || fallbackSettings.logoUrl,
|
||||||
headerCta: result.settings.headerCta,
|
headerCta: result.settings.headerCta,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,32 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import type { MediaFile } from "@/lib/admin/media";
|
import type { MediaFile } from "@/lib/admin/media";
|
||||||
import type { ContentDocuments, ContentKey, LinkContent, PageContent, ServiceContent, TextBlockContent } from "@/lib/content/types";
|
import type { ContentDocuments, ContentKey, LinkContent, PageContent, SeoContent, ServiceContent, TextBlockContent } from "@/lib/content/types";
|
||||||
|
|
||||||
type Viewport = "desktop" | "tablet" | "mobile";
|
type Viewport = "desktop" | "tablet" | "mobile";
|
||||||
type ContentTabId = ContentKey | "footer" | "global-seo" | "general-settings";
|
type ContentTabId = "home" | "services" | "radio-service" | "about" | "contact" | "footer" | "seo";
|
||||||
|
type SeoPageKey = Exclude<ContentKey, "settings">;
|
||||||
|
|
||||||
const tabs: Array<{ id: ContentTabId; key: ContentKey; label: string }> = [
|
const tabs: Array<{ id: ContentTabId; label: string }> = [
|
||||||
{ id: "home", key: "home", label: "Startseite" },
|
{ id: "home", label: "Startseite" },
|
||||||
{ id: "services", key: "services", label: "Leistungen" },
|
{ id: "services", label: "Leistungen" },
|
||||||
{ id: "radio-service", key: "radio-service", label: "Funkgeräte-Service" },
|
{ id: "radio-service", label: "Funkgeräte-Service" },
|
||||||
{ id: "about", key: "about", label: "Über Uns" },
|
{ id: "about", label: "Über uns" },
|
||||||
{ id: "contact", key: "contact", label: "Kontakt" },
|
{ id: "contact", label: "Kontakt" },
|
||||||
{ id: "footer", key: "settings", label: "Footer" },
|
{ id: "footer", label: "Footer" },
|
||||||
{ id: "global-seo", key: "settings", label: "SEO" },
|
{ id: "seo", label: "SEO" },
|
||||||
{ id: "general-settings", key: "settings", label: "Allgemeine Einstellungen" },
|
];
|
||||||
|
|
||||||
|
const seoPages: Array<{ key: SeoPageKey; label: string }> = [
|
||||||
|
{ key: "home", label: "Startseite" },
|
||||||
|
{ key: "services", label: "Leistungen" },
|
||||||
|
{ key: "radio-service", label: "Funkgeräte-Service" },
|
||||||
|
{ key: "repair", label: "Reparatur" },
|
||||||
|
{ key: "about", label: "Über uns" },
|
||||||
|
{ key: "contact", label: "Kontakt" },
|
||||||
];
|
];
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -28,6 +39,12 @@ type ApiResponse<K extends ContentKey> = {
|
||||||
content?: ContentDocuments[K];
|
content?: ContentDocuments[K];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function contentKeyForTab(tab: ContentTabId, seoPage: SeoPageKey): ContentKey {
|
||||||
|
if (tab === "footer") return "settings";
|
||||||
|
if (tab === "seo") return seoPage;
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
function linesToText(lines: string[]) {
|
function linesToText(lines: string[]) {
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
@ -58,23 +75,41 @@ function TextArea({ label, value, onChange, rows = 4 }: { label: string; value:
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImageSelect({ label, value, mediaFiles, onChange }: { label: string; value: string; mediaFiles: MediaFile[]; onChange: (value: string) => void }) {
|
function SectionTitle({ children }: { children: string }) {
|
||||||
|
return <h3 className="content-editor-heading">{children}</h3>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImagePicker({ label, value, mediaFiles, onChange, fallback = "" }: { label: string; value: string; mediaFiles: MediaFile[]; fallback?: string; onChange: (value: string) => void }) {
|
||||||
const images = mediaFiles.filter((file) => file.isImage);
|
const images = mediaFiles.filter((file) => file.isImage);
|
||||||
|
const preview = value || fallback;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="content-image-picker">
|
||||||
<label>
|
<label>
|
||||||
{label}
|
{label}
|
||||||
<select value={value} onChange={(event) => onChange(event.target.value)}>
|
<select value={value} onChange={(event) => onChange(event.target.value)}>
|
||||||
|
<option value="">Standard verwenden</option>
|
||||||
<option value="/funktechnik_schubert_logo.jpg">Standard-Logo</option>
|
<option value="/funktechnik_schubert_logo.jpg">Standard-Logo</option>
|
||||||
<option value="/workbench-signal.svg">Standard-Hintergrund</option>
|
<option value="/workbench-signal.svg">Standard-Hintergrund</option>
|
||||||
{images.map((file) => <option key={file.name} value={file.url}>{file.name}</option>)}
|
{images.map((file) => <option key={file.name} value={file.url}>{file.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
{preview && (
|
||||||
|
<div className="content-image-preview">
|
||||||
|
<img src={preview} alt="" />
|
||||||
|
<button className="button light" type="button" onClick={() => onChange("")}>Bild entfernen</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionTitle({ children }: { children: string }) {
|
function SaveActions({ pending, onSave }: { pending: boolean; onSave: () => void }) {
|
||||||
return <h3 className="content-editor-heading">{children}</h3>;
|
return (
|
||||||
|
<div className="content-save-actions">
|
||||||
|
<button className="button" type="button" onClick={onSave} disabled={pending}>{pending ? "Speichert..." : "Speichern"}</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BlocksEditor({ blocks, onChange }: { blocks: TextBlockContent[]; onChange: (blocks: TextBlockContent[]) => void }) {
|
function BlocksEditor({ blocks, onChange }: { blocks: TextBlockContent[]; onChange: (blocks: TextBlockContent[]) => void }) {
|
||||||
|
|
@ -96,7 +131,7 @@ function BlocksEditor({ blocks, onChange }: { blocks: TextBlockContent[]; onChan
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PageFields({ page, mediaFiles, onChange }: { page: PageContent; mediaFiles: MediaFile[]; onChange: (page: PageContent) => void }) {
|
function PageFields({ page, onChange }: { page: PageContent; onChange: (page: PageContent) => void }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionTitle>Hero</SectionTitle>
|
<SectionTitle>Hero</SectionTitle>
|
||||||
|
|
@ -118,20 +153,24 @@ function PageFields({ page, mediaFiles, onChange }: { page: PageContent; mediaFi
|
||||||
<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 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) } })} />
|
<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>
|
</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 SeoFields({ seo, mediaFiles, onChange }: { seo: SeoContent; mediaFiles: MediaFile[]; onChange: (seo: SeoContent) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="content-field-grid">
|
||||||
|
<TextInput label="Meta Title" value={seo.metaTitle} onChange={(value) => onChange({ ...seo, metaTitle: value })} />
|
||||||
|
<TextInput label="Meta Description" value={seo.metaDescription} onChange={(value) => onChange({ ...seo, metaDescription: value })} />
|
||||||
|
<TextInput label="Keywords" value={seo.keywords} onChange={(value) => onChange({ ...seo, keywords: value })} />
|
||||||
|
<TextInput label="OpenGraph Title" value={seo.openGraphTitle} onChange={(value) => onChange({ ...seo, openGraphTitle: value })} />
|
||||||
|
<TextInput label="OpenGraph Description" value={seo.openGraphDescription} onChange={(value) => onChange({ ...seo, openGraphDescription: value })} />
|
||||||
|
<ImagePicker label="OpenGraph Image" value={seo.socialImage} mediaFiles={mediaFiles} fallback="/funktechnik_schubert_logo.jpg" onChange={(value) => onChange({ ...seo, socialImage: value })} />
|
||||||
|
<TextInput label="Canonical URL" value={seo.canonicalUrl} onChange={(value) => onChange({ ...seo, canonicalUrl: value })} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ServicesEditor({ services, onChange }: { services: ServiceContent[]; onChange: (services: ServiceContent[]) => void }) {
|
function ServicesEditor({ services, onChange }: { services: ServiceContent[]; onChange: (services: ServiceContent[]) => void }) {
|
||||||
function update(index: number, field: keyof ServiceContent, value: string | number | boolean) {
|
function update(index: number, field: keyof ServiceContent, value: string | number | boolean) {
|
||||||
onChange(services.map((service, current) => current === index ? { ...service, [field]: value } : service));
|
onChange(services.map((service, current) => current === index ? { ...service, [field]: value } : service));
|
||||||
|
|
@ -165,14 +204,16 @@ function ServicesEditor({ services, onChange }: { services: ServiceContent[]; on
|
||||||
export default function ContentManager({ initialContent, mediaFiles }: Props) {
|
export default function ContentManager({ initialContent, mediaFiles }: Props) {
|
||||||
const [content, setContent] = useState(initialContent);
|
const [content, setContent] = useState(initialContent);
|
||||||
const [activeTab, setActiveTab] = useState<ContentTabId>("home");
|
const [activeTab, setActiveTab] = useState<ContentTabId>("home");
|
||||||
|
const [seoPage, setSeoPage] = useState<SeoPageKey>("home");
|
||||||
const [viewport, setViewport] = useState<Viewport>("desktop");
|
const [viewport, setViewport] = useState<Viewport>("desktop");
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
const active = tabs.find((tab) => tab.id === activeTab)?.key ?? "home";
|
const active = contentKeyForTab(activeTab, seoPage);
|
||||||
const current = content[active];
|
const current = content[active];
|
||||||
const activeLabel = useMemo(() => tabs.find((tab) => tab.id === activeTab)?.label ?? active, [active, activeTab]);
|
const activeLabel = useMemo(() => tabs.find((tab) => tab.id === activeTab)?.label ?? "Startseite", [activeTab]);
|
||||||
|
const pagePreview = "title" in current ? current : content.home;
|
||||||
|
|
||||||
function update<K extends ContentKey>(key: K, value: ContentDocuments[K]) {
|
function update<K extends ContentKey>(key: K, value: ContentDocuments[K]) {
|
||||||
setContent((previous) => ({ ...previous, [key]: value }));
|
setContent((previous) => ({ ...previous, [key]: value }));
|
||||||
|
|
@ -190,17 +231,17 @@ export default function ContentManager({ initialContent, mediaFiles }: Props) {
|
||||||
body: JSON.stringify({ key: active, content: current }),
|
body: JSON.stringify({ key: active, content: current }),
|
||||||
});
|
});
|
||||||
const result = await response.json() as ApiResponse<typeof active>;
|
const result = await response.json() as ApiResponse<typeof active>;
|
||||||
if (!response.ok || !result.content) throw new Error(result.message ?? "Inhalt konnte nicht gespeichert werden.");
|
if (!response.ok || !result.content) throw new Error("Inhalt konnte nicht gespeichert werden.");
|
||||||
update(active, result.content);
|
update(active, result.content);
|
||||||
setMessage(result.message ?? "Inhalt gespeichert.");
|
setMessage("Inhalt wurde gespeichert.");
|
||||||
} catch (err) {
|
} catch {
|
||||||
setError(err instanceof Error ? err.message : "Inhalt konnte nicht gespeichert werden.");
|
setError("Der Inhalt konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben und versuchen Sie es erneut.");
|
||||||
} finally {
|
} finally {
|
||||||
setPending(false);
|
setPending(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pagePreview = "title" in current ? current : content.home;
|
const heroBackground = content.home.heroImage || "/workbench-signal.svg";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-manager">
|
<div className="content-manager">
|
||||||
|
|
@ -208,8 +249,8 @@ export default function ContentManager({ initialContent, mediaFiles }: Props) {
|
||||||
{error && <p className="error admin-message">{error}</p>}
|
{error && <p className="error admin-message">{error}</p>}
|
||||||
<section className="admin-card content-editor-layout">
|
<section className="admin-card content-editor-layout">
|
||||||
<aside className="content-tabs" aria-label="Website-Inhalte">
|
<aside className="content-tabs" aria-label="Website-Inhalte">
|
||||||
{tabs.map((tab, index) => (
|
{tabs.map((tab) => (
|
||||||
<button key={`${tab.label}-${index}`} type="button" aria-current={activeTab === tab.id ? "page" : undefined} onClick={() => setActiveTab(tab.id)}>
|
<button key={tab.id} type="button" aria-current={activeTab === tab.id ? "page" : undefined} onClick={() => setActiveTab(tab.id)}>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
@ -220,15 +261,14 @@ export default function ContentManager({ initialContent, mediaFiles }: Props) {
|
||||||
<p className="eyebrow">Editor</p>
|
<p className="eyebrow">Editor</p>
|
||||||
<h2>{activeLabel}</h2>
|
<h2>{activeLabel}</h2>
|
||||||
</div>
|
</div>
|
||||||
<button className="button" type="button" onClick={save} disabled={pending}>{pending ? "Speichert..." : "Speichern"}</button>
|
<SaveActions pending={pending} onSave={save} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{"title" in current && (
|
{activeTab === "home" && (
|
||||||
<PageFields page={current} mediaFiles={mediaFiles} onChange={(value) => update(active, value as ContentDocuments[typeof active])} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{active === "home" && (
|
|
||||||
<>
|
<>
|
||||||
|
<PageFields page={content.home} onChange={(value) => update("home", { ...content.home, ...value })} />
|
||||||
|
<SectionTitle>Medien</SectionTitle>
|
||||||
|
<ImagePicker label="Hero-Bild" value={content.home.heroImage} mediaFiles={mediaFiles} fallback="/workbench-signal.svg" onChange={(value) => update("home", { ...content.home, heroImage: value })} />
|
||||||
<SectionTitle>Homepage Builder</SectionTitle>
|
<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) })} />
|
<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>
|
<h4>3 Featureboxen</h4>
|
||||||
|
|
@ -243,15 +283,17 @@ export default function ContentManager({ initialContent, mediaFiles }: Props) {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{active === "services" && (
|
{activeTab === "services" && (
|
||||||
<>
|
<>
|
||||||
|
<PageFields page={content.services} onChange={(value) => update("services", { ...content.services, ...value })} />
|
||||||
<SectionTitle>Leistungen</SectionTitle>
|
<SectionTitle>Leistungen</SectionTitle>
|
||||||
<ServicesEditor services={content.services.services} onChange={(services) => update("services", { ...content.services, services })} />
|
<ServicesEditor services={content.services.services} onChange={(services) => update("services", { ...content.services, services })} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{active === "radio-service" && (
|
{activeTab === "radio-service" && (
|
||||||
<>
|
<>
|
||||||
|
<PageFields page={content["radio-service"]} onChange={(value) => update("radio-service", { ...content["radio-service"], ...value })} />
|
||||||
<SectionTitle>Funkgeräte-Service</SectionTitle>
|
<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="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="Fehlerbilder" value={linesToText(content["radio-service"].symptoms)} onChange={(value) => update("radio-service", { ...content["radio-service"], symptoms: textToLines(value) })} />
|
||||||
|
|
@ -262,17 +304,19 @@ export default function ContentManager({ initialContent, mediaFiles }: Props) {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{active === "about" && (
|
{activeTab === "about" && (
|
||||||
<>
|
<>
|
||||||
<SectionTitle>Über Uns</SectionTitle>
|
<PageFields page={content.about} onChange={(value) => update("about", { ...content.about, ...value })} />
|
||||||
|
<SectionTitle>Über uns</SectionTitle>
|
||||||
<TextArea label="Firmenbeschreibung" value={content.about.companyDescription} onChange={(value) => update("about", { ...content.about, companyDescription: value })} />
|
<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="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 })} />
|
<TextArea label="Philosophie" value={content.about.philosophy} onChange={(value) => update("about", { ...content.about, philosophy: value })} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{active === "contact" && (
|
{activeTab === "contact" && (
|
||||||
<>
|
<>
|
||||||
|
<PageFields page={content.contact} onChange={(value) => update("contact", { ...content.contact, ...value })} />
|
||||||
<SectionTitle>Kontaktinformationen</SectionTitle>
|
<SectionTitle>Kontaktinformationen</SectionTitle>
|
||||||
<div className="content-field-grid">
|
<div className="content-field-grid">
|
||||||
<TextInput label="Telefon" value={content.contact.phone} onChange={(value) => update("contact", { ...content.contact, phone: value })} />
|
<TextInput label="Telefon" value={content.contact.phone} onChange={(value) => update("contact", { ...content.contact, phone: value })} />
|
||||||
|
|
@ -286,50 +330,49 @@ export default function ContentManager({ initialContent, mediaFiles }: Props) {
|
||||||
|
|
||||||
{activeTab === "footer" && (
|
{activeTab === "footer" && (
|
||||||
<>
|
<>
|
||||||
<SectionTitle>Footer und Einstellungen</SectionTitle>
|
<SectionTitle>Footer und Header</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">
|
<div className="content-field-grid">
|
||||||
<TextInput label="Site Name" value={content.settings.siteName} onChange={(value) => update("settings", { ...content.settings, siteName: value })} />
|
<TextInput label="Site Name" value={content.settings.siteName} onChange={(value) => update("settings", { ...content.settings, siteName: value })} />
|
||||||
|
<ImagePicker label="Logo" value={content.settings.logoUrl} mediaFiles={mediaFiles} fallback="/funktechnik_schubert_logo.jpg" onChange={(value) => update("settings", { ...content.settings, logoUrl: value })} />
|
||||||
|
<TextInput label="Copyright" value={content.settings.copyright} onChange={(value) => update("settings", { ...content.settings, copyright: 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 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) })} />
|
<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>
|
||||||
|
<TextArea label="Footer-Kurztext" value={content.settings.footerShortText} onChange={(value) => update("settings", { ...content.settings, footerShortText: value })} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === "seo" && (
|
||||||
|
<>
|
||||||
|
<SectionTitle>SEO pro Seite</SectionTitle>
|
||||||
|
<label>
|
||||||
|
Seite
|
||||||
|
<select value={seoPage} onChange={(event) => setSeoPage(event.target.value as SeoPageKey)}>
|
||||||
|
{seoPages.map((page) => <option key={page.key} value={page.key}>{page.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<SeoFields seo={content[seoPage].seo} mediaFiles={mediaFiles} onChange={(seo) => update(seoPage, { ...content[seoPage], seo })} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SaveActions pending={pending} onSave={save} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-card content-preview-card">
|
<section className="admin-card content-preview-card">
|
||||||
<div className="content-preview-toolbar">
|
<div className="content-preview-toolbar">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Vorschau</p>
|
||||||
<h2>Live Preview</h2>
|
<h2>Live Preview</h2>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{(["desktop", "tablet", "mobile"] as const).map((item) => (
|
{(["desktop", "tablet", "mobile"] as const).map((item) => (
|
||||||
<button key={item} type="button" aria-current={viewport === item ? "true" : undefined} onClick={() => setViewport(item)}>{item}</button>
|
<button key={item} type="button" aria-current={viewport === item ? "true" : undefined} onClick={() => setViewport(item)}>{item}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`content-preview ${viewport}`}>
|
<div className={`content-preview ${viewport}`} style={{ backgroundImage: `linear-gradient(90deg, rgba(6, 24, 52, 0.95), rgba(8, 42, 96, 0.72)), url("${activeTab === "home" ? heroBackground : pagePreview.heroImage || "/workbench-signal.svg"}")` }}>
|
||||||
<p className="eyebrow">{pagePreview.eyebrow}</p>
|
<p className="eyebrow">{activeTab === "seo" ? content[seoPage].seo.metaTitle || pagePreview.eyebrow : pagePreview.eyebrow}</p>
|
||||||
<h1>{pagePreview.title}</h1>
|
<h1>{pagePreview.title}</h1>
|
||||||
<p className="lead">{pagePreview.subtitle}</p>
|
<p className="lead">{pagePreview.subtitle}</p>
|
||||||
<p>{pagePreview.heroText}</p>
|
<p>{pagePreview.heroText}</p>
|
||||||
|
|
|
||||||
|
|
@ -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.4.0"
|
NEXT_PUBLIC_APP_VERSION: "0.4.1"
|
||||||
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:-}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export const defaultContent: ContentDocuments = {
|
||||||
title: "Funktechnik Schubert",
|
title: "Funktechnik Schubert",
|
||||||
subtitle: "Service, Reparatur und Diagnose für Funkgeräte, Messtechnik und Kommunikationstechnik.",
|
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.",
|
heroText: "Von CB-Funk und Amateurfunk bis zur professionellen Funktechnik: Wir unterstützen bei Fehlersuche, Abgleich, Modulationsproblemen, Frequenzproblemen und technischer Dokumentation.",
|
||||||
|
heroImage: "/workbench-signal.svg",
|
||||||
intro: "Von der ersten Fehlerbeschreibung bis zur dokumentierten Prüfung: Funktechnik Schubert verbindet technische Erfahrung, geeignete Messmittel und strukturierte Kommunikation.",
|
intro: "Von der ersten Fehlerbeschreibung bis zur dokumentierten Prüfung: Funktechnik Schubert verbindet technische Erfahrung, geeignete Messmittel und strukturierte Kommunikation.",
|
||||||
paragraphs: [],
|
paragraphs: [],
|
||||||
cta: {
|
cta: {
|
||||||
|
|
@ -62,6 +63,7 @@ export const defaultContent: ContentDocuments = {
|
||||||
title: "Service für Funkgeräte und Kommunikationstechnik",
|
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.",
|
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.",
|
heroText: "Leistungen für private und professionelle Funktechnik mit klarem Fokus auf Diagnose, Abgleich und Reparatur.",
|
||||||
|
heroImage: "/workbench-signal.svg",
|
||||||
intro: "Die Leistungen können im Adminbereich sortiert, erweitert und ein- oder ausgeblendet werden.",
|
intro: "Die Leistungen können im Adminbereich sortiert, erweitert und ein- oder ausgeblendet werden.",
|
||||||
paragraphs: [],
|
paragraphs: [],
|
||||||
cta: { text: "Reparaturfall vorbereiten", primary: { label: "Reparatur anfragen", href: "/reparatur" }, secondary: { label: "Kontakt aufnehmen", href: "/kontakt" } },
|
cta: { text: "Reparaturfall vorbereiten", primary: { label: "Reparatur anfragen", href: "/reparatur" }, secondary: { label: "Kontakt aufnehmen", href: "/kontakt" } },
|
||||||
|
|
@ -83,6 +85,7 @@ export const defaultContent: ContentDocuments = {
|
||||||
title: "Prüfung, Diagnose und Abgleich",
|
title: "Prüfung, Diagnose und Abgleich",
|
||||||
subtitle: "Funkgeräte zeigen Fehler oft erst im Zusammenspiel von Empfang, Sendeteil, Versorgung, Antennenanpassung, Bedienung 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.",
|
heroText: "Der Service betrachtet diese Zusammenhänge strukturiert und dokumentiert die technischen Schritte nachvollziehbar.",
|
||||||
|
heroImage: "/workbench-signal.svg",
|
||||||
intro: "Eine gute Reparatur beginnt mit vollständigen Angaben zu Gerät, Fehlerbild und Vorgeschichte.",
|
intro: "Eine gute Reparatur beginnt mit vollständigen Angaben zu Gerät, Fehlerbild und Vorgeschichte.",
|
||||||
paragraphs: [],
|
paragraphs: [],
|
||||||
cta: { text: "Reparaturprozess starten", primary: { label: "Reparatur anfragen", href: "/reparatur" }, secondary: { label: "Kontakt", href: "/kontakt" } },
|
cta: { text: "Reparaturprozess starten", primary: { label: "Reparatur anfragen", href: "/reparatur" }, secondary: { label: "Kontakt", href: "/kontakt" } },
|
||||||
|
|
@ -105,11 +108,35 @@ export const defaultContent: ContentDocuments = {
|
||||||
seo: seo("Funkgeräte-Service", "Werkstattservice für CB-Funk, Amateurfunk und Funkgeräte-Abgleich.", "/funkgeraete-service"),
|
seo: seo("Funkgeräte-Service", "Werkstattservice für CB-Funk, Amateurfunk und Funkgeräte-Abgleich.", "/funkgeraete-service"),
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
},
|
},
|
||||||
|
repair: {
|
||||||
|
eyebrow: "Reparaturannahme",
|
||||||
|
title: "Reparatur anfragen",
|
||||||
|
subtitle: "Beschreiben Sie Gerät, Fehlerbild, Zubehör und bisherige Vorarbeiten.",
|
||||||
|
heroText: "Die Anfrage wird strukturiert erfasst und für eine spätere technische Bearbeitung vorbereitet.",
|
||||||
|
heroImage: "/workbench-signal.svg",
|
||||||
|
intro: "Bitte senden Sie Geräte erst nach Rückmeldung ein. Vollständige Gerätedaten und eine präzise Fehlerbeschreibung helfen, den Aufwand besser einzuschätzen.",
|
||||||
|
paragraphs: [
|
||||||
|
{ title: "Vor der Einsendung", text: "Bitte senden Sie Geräte erst nach Rückmeldung ein. Vollständige Gerätedaten und eine präzise Fehlerbeschreibung helfen, den Aufwand besser einzuschätzen." },
|
||||||
|
],
|
||||||
|
cta: { text: "Reparatur vorbereiten", primary: { label: "Formular ausfüllen", href: "/reparatur" }, secondary: { label: "Kontakt", href: "/kontakt" } },
|
||||||
|
listTitle: "Wichtige Angaben",
|
||||||
|
list: [
|
||||||
|
"Hersteller, Modell, Geräteart und Seriennummer bereithalten, soweit vorhanden",
|
||||||
|
"Fehler möglichst konkret beschreiben: Empfang, Sendung, Modulation, Frequenz, Anzeige oder Versorgung",
|
||||||
|
"Zubehör wie Netzteil, Mikrofon, Antennenadapter oder Kabel angeben",
|
||||||
|
"Bereits geöffnete Geräte und durchgeführte Arbeiten ehrlich nennen",
|
||||||
|
"Fotos können später nach Rückmeldung ergänzt werden",
|
||||||
|
],
|
||||||
|
faq: [],
|
||||||
|
seo: seo("Reparaturannahme", "Reparaturanfrage für Funkgeräte strukturiert vorbereiten.", "/reparatur"),
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
about: {
|
about: {
|
||||||
eyebrow: "Über uns",
|
eyebrow: "Über uns",
|
||||||
title: "Technik verstehen, Fehler nachvollziehbar lösen",
|
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.",
|
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.",
|
heroText: "Im Mittelpunkt stehen saubere Diagnose, transparente Kommunikation und respektvoller Umgang mit bestehenden Geräten.",
|
||||||
|
heroImage: "/workbench-signal.svg",
|
||||||
intro: "Technischer Service lebt von Genauigkeit, Erfahrung und klarer Rückmeldung.",
|
intro: "Technischer Service lebt von Genauigkeit, Erfahrung und klarer Rückmeldung.",
|
||||||
paragraphs: [
|
paragraphs: [
|
||||||
{ title: "Arbeitsweise", text: "Im Mittelpunkt stehen saubere Diagnose, transparente Kommunikation und der respektvolle Umgang mit bestehenden Geräten und Serviceunterlagen." },
|
{ title: "Arbeitsweise", text: "Im Mittelpunkt stehen saubere Diagnose, transparente Kommunikation und der respektvolle Umgang mit bestehenden Geräten und Serviceunterlagen." },
|
||||||
|
|
@ -130,6 +157,7 @@ export const defaultContent: ContentDocuments = {
|
||||||
title: "Kontakt aufnehmen",
|
title: "Kontakt aufnehmen",
|
||||||
subtitle: "Beschreiben Sie kurz Ihr Anliegen. Für Reparaturen nutzen Sie idealerweise die strukturierte Reparaturannahme.",
|
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.",
|
heroText: "Je genauer Gerät, Anliegen und gewünschte Unterstützung beschrieben werden, desto gezielter kann die Rückmeldung erfolgen.",
|
||||||
|
heroImage: "/workbench-signal.svg",
|
||||||
intro: "Direkt und technisch: Nutzen Sie das Kontaktformular oder die Reparaturannahme.",
|
intro: "Direkt und technisch: Nutzen Sie das Kontaktformular oder die Reparaturannahme.",
|
||||||
paragraphs: [],
|
paragraphs: [],
|
||||||
cta: { text: "Anfrage senden", primary: { label: "Reparatur anfragen", href: "/reparatur" }, secondary: { label: "Leistungen", href: "/leistungen" } },
|
cta: { text: "Anfrage senden", primary: { label: "Reparatur anfragen", href: "/reparatur" }, secondary: { label: "Leistungen", href: "/leistungen" } },
|
||||||
|
|
@ -170,6 +198,7 @@ export const contentFileNames = {
|
||||||
home: "home.json",
|
home: "home.json",
|
||||||
services: "services.json",
|
services: "services.json",
|
||||||
"radio-service": "radio-service.json",
|
"radio-service": "radio-service.json",
|
||||||
|
repair: "repair.json",
|
||||||
about: "about.json",
|
about: "about.json",
|
||||||
contact: "contact.json",
|
contact: "contact.json",
|
||||||
settings: "settings.json",
|
settings: "settings.json",
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ import path from "path";
|
||||||
import { storageDirectory } from "@/lib/runtime/config";
|
import { storageDirectory } from "@/lib/runtime/config";
|
||||||
import { listMediaFiles } from "@/lib/admin/media";
|
import { listMediaFiles } from "@/lib/admin/media";
|
||||||
import { contentFileNames, defaultContent } from "./defaults";
|
import { contentFileNames, defaultContent } from "./defaults";
|
||||||
import type { ContentDocuments, ContentKey, ContentSummary } from "./types";
|
import type { ContentBackup, ContentDocuments, ContentKey, ContentSummary } from "./types";
|
||||||
|
|
||||||
export const contentDirectory = path.join(storageDirectory, "content");
|
export const contentDirectory = path.join(storageDirectory, "content");
|
||||||
export const contentBackupDirectory = path.join(contentDirectory, "backups");
|
export const contentBackupDirectory = path.join(contentDirectory, "backups");
|
||||||
export const contentVersion = "0.4.0";
|
export const contentVersion = "0.4.1";
|
||||||
|
|
||||||
const contentKeys = Object.keys(contentFileNames) as ContentKey[];
|
const contentKeys = Object.keys(contentFileNames) as ContentKey[];
|
||||||
|
|
||||||
|
|
@ -51,7 +51,7 @@ function filePathFor(key: ContentKey) {
|
||||||
function backupStamp() {
|
function backupStamp() {
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
const pad = (value: number) => String(value).padStart(2, "0");
|
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())}`;
|
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pruneBackups(key: ContentKey) {
|
async function pruneBackups(key: ContentKey) {
|
||||||
|
|
@ -144,10 +144,33 @@ export async function getContentSummary(): Promise<ContentSummary> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listContentBackups(limit = 20): Promise<ContentBackup[]> {
|
||||||
|
await ensureContentStorage();
|
||||||
|
const entries = await readdir(contentBackupDirectory).catch(() => []);
|
||||||
|
const backups = await Promise.all(entries
|
||||||
|
.filter((entry) => entry.endsWith(".json"))
|
||||||
|
.map(async (entry) => {
|
||||||
|
const page = entry.split(".")[0] as ContentKey;
|
||||||
|
const fullPath = path.join(contentBackupDirectory, entry);
|
||||||
|
const fileStat = await stat(fullPath);
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
fileName: entry,
|
||||||
|
createdAt: fileStat.mtime.toISOString(),
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
return backups
|
||||||
|
.filter((backup) => contentKeys.includes(backup.page))
|
||||||
|
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getContentSystemStatus() {
|
export async function getContentSystemStatus() {
|
||||||
const [summary, media] = await Promise.all([getContentSummary(), listMediaFiles()]);
|
const [summary, media, backups] = await Promise.all([getContentSummary(), listMediaFiles(), listContentBackups()]);
|
||||||
return {
|
return {
|
||||||
...summary,
|
...summary,
|
||||||
mediaCount: media.length,
|
mediaCount: media.length,
|
||||||
|
backups,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ export type PageContent = {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
heroText: string;
|
heroText: string;
|
||||||
|
heroImage: string;
|
||||||
intro: string;
|
intro: string;
|
||||||
paragraphs: TextBlockContent[];
|
paragraphs: TextBlockContent[];
|
||||||
cta: {
|
cta: {
|
||||||
|
|
@ -92,6 +93,8 @@ export type ContactContent = PageContent & {
|
||||||
socialMedia: LinkContent[];
|
socialMedia: LinkContent[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RepairContent = PageContent;
|
||||||
|
|
||||||
export type SettingsContent = {
|
export type SettingsContent = {
|
||||||
siteName: string;
|
siteName: string;
|
||||||
logoUrl: string;
|
logoUrl: string;
|
||||||
|
|
@ -108,6 +111,7 @@ export type ContentDocuments = {
|
||||||
home: HomeContent;
|
home: HomeContent;
|
||||||
services: ServicesContent;
|
services: ServicesContent;
|
||||||
"radio-service": RadioServiceContent;
|
"radio-service": RadioServiceContent;
|
||||||
|
repair: RepairContent;
|
||||||
about: AboutContent;
|
about: AboutContent;
|
||||||
contact: ContactContent;
|
contact: ContactContent;
|
||||||
settings: SettingsContent;
|
settings: SettingsContent;
|
||||||
|
|
@ -126,3 +130,9 @@ export type ContentSummary = {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ContentBackup = {
|
||||||
|
page: ContentKey;
|
||||||
|
fileName: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
export const appVersion = "0.4.0";
|
export const appVersion = "0.4.1";
|
||||||
|
|
|
||||||
40
package-lock.json
generated
40
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "funktechnik-schubert-website",
|
"name": "funktechnik-schubert-website",
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "funktechnik-schubert-website",
|
"name": "funktechnik-schubert-website",
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"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.4.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.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.4.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.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.4.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.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.4.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.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.4.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.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.4.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.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.4.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.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.4.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.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.4.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.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.4.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.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.4.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.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.4.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.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.4.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.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.4.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
|
|
@ -2711,9 +2711,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.385",
|
"version": "1.5.387",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.385.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz",
|
||||||
"integrity": "sha512-78sa/M08MNAYHQfjoWMvOlKQqZ0ElhSm/L5HNUc96VZ3b+KvDVnngFm8sYQy0XrhTRgAhggHr5abA7yTvRdo4Q==",
|
"integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
|
@ -5313,7 +5313,7 @@
|
||||||
"semver": "^7.7.3"
|
"semver": "^7.7.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.4.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.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.4.0",
|
"version": "0.4.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3010",
|
"dev": "next dev -p 3010",
|
||||||
|
|
|
||||||
|
|
@ -56,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.4.0"}.\n`);
|
process.stdout.write(`[funktechnik-website] Runtime checks ok. Starting version ${process.env.NEXT_PUBLIC_APP_VERSION ?? "0.4.1"}.\n`);
|
||||||
require("./server.js");
|
require("./server.js");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue