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,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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 }>) {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
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 {
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue