feat(cms): add storage based website content management

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

View file

@ -1,33 +1,82 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useState } from "react";
import { appVersion } from "@/lib/runtime/version";
import type { SettingsContent } from "@/lib/content/types";
const fallbackSettings: SettingsContent = {
siteName: "Funktechnik Schubert",
logoUrl: "/funktechnik_schubert_logo.jpg",
footerShortText: "Service und technische Unterstützung für Funkgeräte, Messtechnik und Kommunikationstechnik.",
copyright: "© Funktechnik Schubert",
footerLinks: [
{ label: "Leistungen", href: "/leistungen" },
{ label: "Funkgeräte-Service", href: "/funkgeraete-service" },
{ label: "Reparatur", href: "/reparatur" },
{ label: "Kontakt", href: "/kontakt" },
],
legalLinks: [
{ label: "Impressum", href: "/impressum" },
{ label: "Datenschutz", href: "/datenschutz" },
],
headerCta: { label: "Reparatur anfragen", href: "/reparatur" },
seo: {
metaTitle: "Funktechnik Schubert",
metaDescription: "",
keywords: "",
openGraphTitle: "",
openGraphDescription: "",
socialImage: "/funktechnik_schubert_logo.jpg",
canonicalUrl: "/",
},
updatedAt: "",
};
export default function Footer() {
const [settings, setSettings] = useState(fallbackSettings);
useEffect(() => {
let active = true;
async function loadSettings() {
try {
const response = await fetch("/api/content/settings");
const result = await response.json() as { settings?: SettingsContent };
if (active && response.ok && result.settings) setSettings(result.settings);
} catch {
if (active) setSettings(fallbackSettings);
}
}
void loadSettings();
return () => {
active = false;
};
}, []);
return (
<footer className="footer">
<div className="container footer-grid">
<div>
<Image
src="/funktechnik_schubert_logo.jpg"
alt="Funktechnik Schubert"
src={settings.logoUrl}
alt={settings.siteName}
width={1672}
height={941}
className="footer-logo"
/>
<p>Funktechnik Schubert Service und technische Unterstützung für Funkgeräte, Messtechnik und Kommunikationstechnik.</p>
<p className="copyright">© Funktechnik Schubert · v{appVersion}</p>
<p>{settings.footerShortText}</p>
<p className="copyright">{settings.copyright} · v{appVersion}</p>
</div>
<div>
<h3>Website</h3>
<p><Link href="/leistungen">Leistungen</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>
{settings.footerLinks.map((link) => <p key={`${link.href}-${link.label}`}><Link href={link.href}>{link.label}</Link></p>)}
</div>
<div>
<h3>Rechtliches</h3>
<p><Link href="/impressum">Impressum</Link></p>
<p><Link href="/datenschutz">Datenschutz</Link></p>
{settings.legalLinks.map((link) => <p key={`${link.href}-${link.label}`}><Link href={link.href}>{link.label}</Link></p>)}
</div>
</div>
</footer>

View file

@ -3,7 +3,8 @@
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { useEffect, useState } from "react";
import type { SettingsContent } from "@/lib/content/types";
const navigation = [
["Start", "/"],
@ -14,17 +15,49 @@ const navigation = [
["Kontakt", "/kontakt"],
] as const;
const fallbackSettings = {
siteName: "Funktechnik Schubert",
logoUrl: "/funktechnik_schubert_logo.jpg",
headerCta: { label: "Reparatur anfragen", href: "/reparatur" },
};
export default function Header() {
const pathname = usePathname();
const [menuOpen, setMenuOpen] = useState(false);
const [settings, setSettings] = useState(fallbackSettings);
useEffect(() => {
let active = true;
async function loadSettings() {
try {
const response = await fetch("/api/content/settings");
const result = await response.json() as { settings?: SettingsContent };
if (active && response.ok && result.settings) {
setSettings({
siteName: result.settings.siteName,
logoUrl: result.settings.logoUrl,
headerCta: result.settings.headerCta,
});
}
} catch {
if (active) setSettings(fallbackSettings);
}
}
void loadSettings();
return () => {
active = false;
};
}, []);
return (
<header className="site-header">
<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
src="/funktechnik_schubert_logo.jpg"
alt="Funktechnik Schubert"
src={settings.logoUrl}
alt={settings.siteName}
width={1672}
height={941}
priority
@ -47,7 +80,7 @@ export default function Header() {
</Link>
))}
</nav>
<Link className="header-cta" href="/reparatur">Reparatur anfragen</Link>
<Link className="header-cta" href={settings.headerCta.href}>{settings.headerCta.label}</Link>
</div>
</header>
);

View file

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