feat(cms): improve admin content editing
This commit is contained in:
parent
12d31f5468
commit
a284bbc188
26 changed files with 362 additions and 147 deletions
|
|
@ -61,7 +61,7 @@ export default function Footer() {
|
|||
<div className="container footer-grid">
|
||||
<div>
|
||||
<Image
|
||||
src={settings.logoUrl}
|
||||
src={settings.logoUrl || fallbackSettings.logoUrl}
|
||||
alt={settings.siteName}
|
||||
width={1672}
|
||||
height={941}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export default function Header() {
|
|||
if (active && response.ok && result.settings) {
|
||||
setSettings({
|
||||
siteName: result.settings.siteName,
|
||||
logoUrl: result.settings.logoUrl,
|
||||
logoUrl: result.settings.logoUrl || fallbackSettings.logoUrl,
|
||||
headerCta: result.settings.headerCta,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,32 @@
|
|||
"use client";
|
||||
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
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 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 }> = [
|
||||
{ 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" },
|
||||
const tabs: Array<{ id: ContentTabId; label: string }> = [
|
||||
{ id: "home", label: "Startseite" },
|
||||
{ id: "services", label: "Leistungen" },
|
||||
{ id: "radio-service", label: "Funkgeräte-Service" },
|
||||
{ id: "about", label: "Über uns" },
|
||||
{ id: "contact", label: "Kontakt" },
|
||||
{ id: "footer", label: "Footer" },
|
||||
{ id: "seo", label: "SEO" },
|
||||
];
|
||||
|
||||
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 = {
|
||||
|
|
@ -28,6 +39,12 @@ type ApiResponse<K extends ContentKey> = {
|
|||
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[]) {
|
||||
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 preview = value || fallback;
|
||||
|
||||
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>
|
||||
<div className="content-image-picker">
|
||||
<label>
|
||||
{label}
|
||||
<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="/workbench-signal.svg">Standard-Hintergrund</option>
|
||||
{images.map((file) => <option key={file.name} value={file.url}>{file.name}</option>)}
|
||||
</select>
|
||||
</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 }) {
|
||||
return <h3 className="content-editor-heading">{children}</h3>;
|
||||
function SaveActions({ pending, onSave }: { pending: boolean; onSave: () => void }) {
|
||||
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 }) {
|
||||
|
|
@ -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 (
|
||||
<>
|
||||
<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 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 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 update(index: number, field: keyof ServiceContent, value: string | number | boolean) {
|
||||
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) {
|
||||
const [content, setContent] = useState(initialContent);
|
||||
const [activeTab, setActiveTab] = useState<ContentTabId>("home");
|
||||
const [seoPage, setSeoPage] = useState<SeoPageKey>("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 active = contentKeyForTab(activeTab, seoPage);
|
||||
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]) {
|
||||
setContent((previous) => ({ ...previous, [key]: value }));
|
||||
|
|
@ -190,17 +231,17 @@ export default function ContentManager({ initialContent, mediaFiles }: Props) {
|
|||
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.");
|
||||
if (!response.ok || !result.content) throw new Error("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.");
|
||||
setMessage("Inhalt wurde gespeichert.");
|
||||
} catch {
|
||||
setError("Der Inhalt konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben und versuchen Sie es erneut.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
const pagePreview = "title" in current ? current : content.home;
|
||||
const heroBackground = content.home.heroImage || "/workbench-signal.svg";
|
||||
|
||||
return (
|
||||
<div className="content-manager">
|
||||
|
|
@ -208,8 +249,8 @@ export default function ContentManager({ initialContent, mediaFiles }: Props) {
|
|||
{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)}>
|
||||
{tabs.map((tab) => (
|
||||
<button key={tab.id} type="button" aria-current={activeTab === tab.id ? "page" : undefined} onClick={() => setActiveTab(tab.id)}>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
|
|
@ -220,15 +261,14 @@ export default function ContentManager({ initialContent, mediaFiles }: Props) {
|
|||
<p className="eyebrow">Editor</p>
|
||||
<h2>{activeLabel}</h2>
|
||||
</div>
|
||||
<button className="button" type="button" onClick={save} disabled={pending}>{pending ? "Speichert..." : "Speichern"}</button>
|
||||
<SaveActions pending={pending} onSave={save} />
|
||||
</div>
|
||||
|
||||
{"title" in current && (
|
||||
<PageFields page={current} mediaFiles={mediaFiles} onChange={(value) => update(active, value as ContentDocuments[typeof active])} />
|
||||
)}
|
||||
|
||||
{active === "home" && (
|
||||
{activeTab === "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>
|
||||
<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>
|
||||
|
|
@ -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>
|
||||
<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>
|
||||
<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) })} />
|
||||
|
|
@ -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="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" && (
|
||||
{activeTab === "contact" && (
|
||||
<>
|
||||
<PageFields page={content.contact} onChange={(value) => update("contact", { ...content.contact, ...value })} />
|
||||
<SectionTitle>Kontaktinformationen</SectionTitle>
|
||||
<div className="content-field-grid">
|
||||
<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" && (
|
||||
<>
|
||||
<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>
|
||||
<SectionTitle>Footer und Header</SectionTitle>
|
||||
<div className="content-field-grid">
|
||||
<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 Link" value={content.settings.headerCta.href} onChange={(value) => update("settings", { ...content.settings, headerCta: updateLink(content.settings.headerCta, "href", value) })} />
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<section className="admin-card content-preview-card">
|
||||
<div className="content-preview-toolbar">
|
||||
<h2>Live Preview</h2>
|
||||
<div>
|
||||
<p className="eyebrow">Vorschau</p>
|
||||
<h2>Live Preview</h2>
|
||||
</div>
|
||||
<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>
|
||||
<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">{activeTab === "seo" ? content[seoPage].seo.metaTitle || pagePreview.eyebrow : pagePreview.eyebrow}</p>
|
||||
<h1>{pagePreview.title}</h1>
|
||||
<p className="lead">{pagePreview.subtitle}</p>
|
||||
<p>{pagePreview.heroText}</p>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue