feat(cms): improve admin content editing

This commit is contained in:
Schubert Ferenc 2026-07-04 11:01:29 +02:00
parent 12d31f5468
commit a284bbc188
26 changed files with 362 additions and 147 deletions

View file

@ -1,8 +1,11 @@
import { redirect } from "next/navigation";
import AdminShell from "@/components/admin/AdminShell";
import { requireAdminSession } from "@/lib/admin/auth";
import { listMediaFiles } from "@/lib/admin/media";
import { getContactInquiries, getRepairInquiries } from "@/lib/admin/store";
import { getContentSummary } from "@/lib/content/service";
import { getSmtpSettings, isSmtpConfigured } from "@/lib/mail/config";
import { appVersion, checkStorage } from "@/lib/runtime/config";
function countNew<T extends { status: string }>(items: T[]) {
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() {
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 contentModified = contentSummary.lastModified ? new Date(contentSummary.lastModified).toLocaleString("de-DE") : "keine Änderung";
return (
<AdminShell>
@ -20,10 +38,13 @@ export default async function AdminDashboardPage() {
<h1>Dashboard</h1>
</div>
<div className="admin-kpis">
<div className="admin-kpi"><span>Kontaktanfragen</span><strong>{countNew(contacts)}</strong><small>neu</small></div>
<div className="admin-kpi"><span>Reparaturanfragen</span><strong>{countNew(repairs)}</strong><small>neu</small></div>
<div className="admin-kpi"><span>Website</span><strong>Online</strong><small>OK</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>{repairs.length}</strong><small>{countNew(repairs)} neu</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>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>
<section className="admin-card">
<h2>Letzte Anfragen</h2>

View file

@ -52,6 +52,22 @@ export default async function AdminSystemPage() {
<li>SMTP-Versand ist serverseitig aktiv, sofern eine vollständige Konfiguration gespeichert ist.</li>
</ul>
</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>
);
}

View file

@ -3,7 +3,7 @@ import { requireAdminSession } from "@/lib/admin/auth";
import { getAllContent, saveContent } from "@/lib/content/service";
import type { ContentDocuments, ContentKey } from "@/lib/content/types";
const keys: ContentKey[] = ["home", "services", "radio-service", "about", "contact", "settings"];
const keys: ContentKey[] = ["home", "services", "radio-service", "repair", "about", "contact", "settings"];
function isContentKey(value: unknown): value is ContentKey {
return typeof value === "string" && keys.includes(value as ContentKey);

View file

@ -1,5 +1,6 @@
import Link from "next/link";
import PageHero from "@/components/PageHero";
import { defaultContent } from "@/lib/content/defaults";
import { getContent } from "@/lib/content/service";
import { createContentMetadata } from "../seo";
@ -7,7 +8,7 @@ export const dynamic = "force-dynamic";
export async function generateMetadata() {
const content = await getContent("radio-service");
return createContentMetadata(content.seo);
return createContentMetadata(content.seo, defaultContent["radio-service"].seo);
}
export default async function RadioServicePage() {

View file

@ -630,7 +630,7 @@ h3 {
.admin-kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
margin-bottom: 18px;
}
@ -1039,6 +1039,11 @@ h3 {
margin-bottom: 0;
}
.content-save-actions {
display: flex;
justify-content: flex-end;
}
.content-editor-heading {
margin: 14px 0 0;
border-top: 1px solid var(--line);
@ -1094,6 +1099,27 @@ h3 {
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 {
overflow: hidden;
}
@ -1110,6 +1136,10 @@ h3 {
margin-bottom: 0;
}
.content-preview-toolbar .eyebrow {
margin-bottom: 4px;
}
.content-preview-toolbar div {
display: flex;
flex-wrap: wrap;
@ -1273,4 +1303,16 @@ h3 {
.content-field-grid {
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;
}
}

View file

@ -1,5 +1,6 @@
import PageHero from "@/components/PageHero";
import ContactForm from "@/components/ContactForm";
import { defaultContent } from "@/lib/content/defaults";
import { getContent } from "@/lib/content/service";
import { createContentMetadata } from "../seo";
@ -7,7 +8,7 @@ export const dynamic = "force-dynamic";
export async function generateMetadata() {
const content = await getContent("contact");
return createContentMetadata(content.seo);
return createContentMetadata(content.seo, defaultContent.contact.seo);
}
export default async function ContactPage() {

View file

@ -1,12 +1,13 @@
import type { Metadata } from "next";
import SiteFrame from "@/components/SiteFrame";
import "./globals.css";
import { defaultContent } from "@/lib/content/defaults";
import { getContent } from "@/lib/content/service";
import { createContentMetadata } from "./seo";
export async function generateMetadata(): Promise<Metadata> {
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 }>) {

View file

@ -1,5 +1,6 @@
import PageHero from "@/components/PageHero";
import ServiceCard from "@/components/ServiceCard";
import { defaultContent } from "@/lib/content/defaults";
import { getContent } from "@/lib/content/service";
import { createContentMetadata } from "../seo";
@ -7,7 +8,7 @@ export const dynamic = "force-dynamic";
export async function generateMetadata() {
const content = await getContent("services");
return createContentMetadata(content.seo);
return createContentMetadata(content.seo, defaultContent.services.seo);
}
export default async function ServicesPage() {

View file

@ -1,5 +1,6 @@
import Link from "next/link";
import ServiceCard from "@/components/ServiceCard";
import { defaultContent } from "@/lib/content/defaults";
import { getContent } from "@/lib/content/service";
import { createContentMetadata } from "./seo";
@ -7,7 +8,7 @@ export const dynamic = "force-dynamic";
export async function generateMetadata() {
const content = await getContent("home");
return createContentMetadata(content.seo);
return createContentMetadata(content.seo, defaultContent.home.seo);
}
export default async function HomePage() {
@ -15,7 +16,10 @@ export default async function HomePage() {
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">
<p className="eyebrow">{content.eyebrow}</p>
<h1>{content.title}</h1>

View file

@ -1,27 +1,32 @@
import type { Metadata } from "next";
import PageHero from "@/components/PageHero";
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 (
<>
<PageHero eyebrow="Reparaturannahme" title="Reparatur anfragen">
Beschreiben Sie Gerät, Fehlerbild, Zubehör und bisherige Vorarbeiten. Die Anfrage wird strukturiert erfasst und für eine spätere technische Bearbeitung vorbereitet.
<PageHero eyebrow={content.eyebrow} title={content.title}>
{content.subtitle}
</PageHero>
<section className="section">
<div className="container split">
<div>
<h2>Vor der Einsendung</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>
<h2>{paragraph?.title || content.cta.text}</h2>
<p className="lead">{paragraph?.text || content.intro}</p>
<ul className="list">
<li>Hersteller, Modell, Geräteart und Seriennummer bereithalten, soweit vorhanden</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>
{content.list.map((item) => <li key={item}>{item}</li>)}
</ul>
</div>
<div className="panel">

View file

@ -60,28 +60,32 @@ export function createMetadata(title: string, description: string, path = "/"):
};
}
export function createContentMetadata(seo: SeoContent): Metadata {
const path = seo.canonicalUrl || "/";
export function createContentMetadata(seo: SeoContent, fallbackSeo?: SeoContent): Metadata {
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 title = seo.metaTitle || siteName;
const description = seo.metaDescription || defaultTitle;
const title = resolvedSeo.metaTitle || siteName;
const description = resolvedSeo.metaDescription || defaultTitle;
return {
...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: {
type: "website",
locale: "de_DE",
url,
siteName,
title: seo.openGraphTitle || title,
description: seo.openGraphDescription || description,
title: resolvedSeo.openGraphTitle || title,
description: resolvedSeo.openGraphDescription || description,
images: [
{
url: seo.socialImage || "/funktechnik_schubert_logo.jpg",
url: resolvedSeo.socialImage || "/funktechnik_schubert_logo.jpg",
width: 1200,
height: 630,
alt: seo.openGraphTitle || title,
alt: resolvedSeo.openGraphTitle || title,
},
],
},

View file

@ -1,4 +1,5 @@
import PageHero from "@/components/PageHero";
import { defaultContent } from "@/lib/content/defaults";
import { getContent } from "@/lib/content/service";
import { createContentMetadata } from "../seo";
@ -6,7 +7,7 @@ export const dynamic = "force-dynamic";
export async function generateMetadata() {
const content = await getContent("about");
return createContentMetadata(content.seo);
return createContentMetadata(content.seo, defaultContent.about.seo);
}
export default async function AboutPage() {