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

176
lib/content/defaults.ts Normal file
View file

@ -0,0 +1,176 @@
import type { ContentDocuments, SeoContent } from "./types";
const now = "2026-01-01T00:00:00.000Z";
function seo(title: string, description: string, path: string): SeoContent {
return {
metaTitle: title,
metaDescription: description,
keywords: "Funktechnik Schubert, Funkgeräte Reparatur, CB Funk Service, Amateurfunk Service, Funkgeräte Abgleich, Messtechnik",
openGraphTitle: title,
openGraphDescription: description,
socialImage: "/funktechnik_schubert_logo.jpg",
canonicalUrl: path,
};
}
export const defaultContent: ContentDocuments = {
home: {
eyebrow: "Funktechnik, Messtechnik, Werkstattservice",
title: "Funktechnik Schubert",
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.",
intro: "Von der ersten Fehlerbeschreibung bis zur dokumentierten Prüfung: Funktechnik Schubert verbindet technische Erfahrung, geeignete Messmittel und strukturierte Kommunikation.",
paragraphs: [],
cta: {
text: "Direkte technische Einschätzung statt anonymer Abwicklung",
primary: { label: "Reparatur anfragen", href: "/reparatur" },
secondary: { label: "Leistungen ansehen", href: "/leistungen" },
},
listTitle: "Technische Schwerpunkte",
list: ["CB-Funk", "Amateurfunk", "Messtechnik", "Abgleich", "Diagnose", "Service Manuals"],
badges: ["CB-Funk", "Amateurfunk", "Messtechnik", "Abgleich", "Diagnose", "Service Manuals"],
features: [
{ title: "Funkgeräte-Service", text: "Sichtprüfung, Funktionsprüfung und technische Beurteilung für CB-Funk, Amateurfunk, Betriebsfunk sowie ältere und moderne Geräte." },
{ title: "Fehlersuche & Reparatur", text: "Systematische Diagnose bei Empfangsproblemen, Sendeproblemen, PLL-Fehlern, Frequenzabweichungen und Audio-/NF-Störungen." },
{ title: "Abgleich & Messtechnik", text: "Frequenzabgleich, Hub- und Modulationsprüfung, Sendeleistung, Empfindlichkeit und RF-Messung mit geeigneter Messtechnik." },
],
services: [
{ title: "Funkgeräte-Service", text: "Prüfung und technische Einschätzung für CB-Funk, Amateurfunk und Kommunikationstechnik." },
{ title: "Messtechnik", text: "Messungen mit geeigneter Werkstattpraxis und nachvollziehbarer Dokumentation." },
{ title: "Serviceunterlagen", text: "Einordnung von Schaltplänen, Service Manuals und Abgleichhinweisen." },
],
promise: {
eyebrow: "Persönlicher Support",
title: "Direkte technische Einschätzung statt anonymer Abwicklung",
text: "Viele Fehlerbilder lassen sich bereits mit einer klaren Beschreibung und den richtigen Gerätedaten eingrenzen.",
bullets: [
"Technischer Fokus auf Funkgeräte und Kommunikationstechnik",
"Nachvollziehbare Diagnose mit Blick auf Messwerte und Fehlerbild",
"Sauberer Umgang mit Service Manuals, Schaltplänen und Abgleichanleitungen",
"Klare Rückmeldung zu Zustand, Aufwand und sinnvollen nächsten Schritten",
],
button: { label: "Reparaturformular öffnen", href: "/reparatur" },
},
footerText: "Service und technische Unterstützung für Funkgeräte, Messtechnik und Kommunikationstechnik.",
faq: [],
seo: seo("Funktechnik Schubert", "Service, Reparatur, Diagnose und Abgleich von Funktechnik, CB-Funk, Amateurfunk und Kommunikationstechnik.", "/"),
updatedAt: now,
},
services: {
eyebrow: "Leistungen",
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.",
heroText: "Leistungen für private und professionelle Funktechnik mit klarem Fokus auf Diagnose, Abgleich und Reparatur.",
intro: "Die Leistungen können im Adminbereich sortiert, erweitert und ein- oder ausgeblendet werden.",
paragraphs: [],
cta: { text: "Reparaturfall vorbereiten", primary: { label: "Reparatur anfragen", href: "/reparatur" }, secondary: { label: "Kontakt aufnehmen", href: "/kontakt" } },
listTitle: "Leistungen",
list: [],
services: [
{ title: "Funkgeräte-Service", description: "Service für CB-Funk, Amateurfunk, Betriebsfunk sowie ältere und moderne Funkgeräte.", icon: "radio", sortOrder: 10, visible: true },
{ title: "Fehlersuche & Reparatur", description: "Strukturierte Analyse bei Modulationsproblemen, Frequenzabweichung, PLL-Problemen und Empfangs- oder Sendeproblemen.", icon: "tools", sortOrder: 20, visible: true },
{ title: "Abgleich & Prüfung", description: "Frequenzabgleich, Hub- und Modulationsprüfung, Sendeleistung, Empfindlichkeit und Funktionsprüfung.", icon: "meter", sortOrder: 30, visible: true },
{ title: "Messtechnik & Diagnose", description: "Systematische Fehlersuche mit Oszilloskop, Frequenzzähler, Signalgenerator, RF-Messung und Modulationsmessung.", icon: "scope", sortOrder: 40, visible: true },
{ title: "Serviceunterlagen", description: "Einordnung von Schaltplänen, Service Manuals, Abgleichanleitungen und Reparaturhinweisen.", icon: "manual", sortOrder: 50, visible: true },
],
faq: [],
seo: seo("Leistungen", "Funkgeräte-Service, Diagnose, Abgleich und Dokumentation für Kommunikationstechnik.", "/leistungen"),
updatedAt: now,
},
"radio-service": {
eyebrow: "Funkgeräte-Service",
title: "Prüfung, Diagnose 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.",
intro: "Eine gute Reparatur beginnt mit vollständigen Angaben zu Gerät, Fehlerbild und Vorgeschichte.",
paragraphs: [],
cta: { text: "Reparaturprozess starten", primary: { label: "Reparatur anfragen", href: "/reparatur" }, secondary: { label: "Kontakt", href: "/kontakt" } },
listTitle: "Typische Fehlerbilder",
list: [],
brands: ["CB-Funk", "Amateurfunk", "Betriebsfunk"],
symptoms: [
"Kein oder schwacher Empfang",
"Keine, leise oder verzerrte Modulation",
"Frequenzversatz, PLL-Rasten oder instabile Kanäle",
"Sendeprobleme, schwankende Leistung oder auffällige Erwärmung",
"Audio-/NF-Probleme, Displayfehler oder Aussetzer durch Bedienelemente",
"Unklare Vorarbeiten, fehlende Serviceunterlagen oder bereits geöffnete Geräte",
],
workflow: ["Anfrage", "Technische Sichtung", "Messung", "Abstimmung", "Reparatur oder Rückgabe"],
measurements: ["Frequenz", "Sendeleistung", "Hub", "Modulation", "Empfindlichkeit"],
alignment: "Abgleich erfolgt anhand technischer Unterlagen und sinnvoller Messpunkte.",
repair: "Reparaturen werden vor Durchführung technisch eingeordnet und abgestimmt.",
faq: [],
seo: seo("Funkgeräte-Service", "Werkstattservice für CB-Funk, Amateurfunk und Funkgeräte-Abgleich.", "/funkgeraete-service"),
updatedAt: now,
},
about: {
eyebrow: "Über uns",
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.",
heroText: "Im Mittelpunkt stehen saubere Diagnose, transparente Kommunikation und respektvoller Umgang mit bestehenden Geräten.",
intro: "Technischer Service lebt von Genauigkeit, Erfahrung und klarer Rückmeldung.",
paragraphs: [
{ title: "Arbeitsweise", text: "Im Mittelpunkt stehen saubere Diagnose, transparente Kommunikation und der respektvolle Umgang mit bestehenden Geräten und Serviceunterlagen." },
{ title: "Fokus", text: "Der Schwerpunkt liegt auf Funktechnik, Elektronikservice, Abgleichfragen und technischer Unterstützung rund um Kommunikationstechnik." },
],
cta: { text: "Mehr zur Werkstatt", primary: { label: "Kontakt aufnehmen", href: "/kontakt" }, secondary: { label: "Leistungen", href: "/leistungen" } },
listTitle: "Grundsätze",
list: ["Nachvollziehbare technische Einschätzung", "Klare Kommunikation", "Sorgfältiger Umgang mit Geräten"],
companyDescription: "Funktechnik Schubert bietet technischen Service für Funkgeräte, Messtechnik und Kommunikationstechnik.",
workshopDescription: "Die Werkstatt arbeitet strukturiert mit Messmitteln, Dokumentation und technischer Prüfung.",
philosophy: "Reparatur und Service sollen nachvollziehbar, ehrlich und technisch sauber bleiben.",
faq: [],
seo: seo("Über uns", "Funktechnik Schubert steht für technischen Service und klare Kommunikation.", "/ueber-uns"),
updatedAt: now,
},
contact: {
eyebrow: "Kontakt",
title: "Kontakt aufnehmen",
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.",
intro: "Direkt und technisch: Nutzen Sie das Kontaktformular oder die Reparaturannahme.",
paragraphs: [],
cta: { text: "Anfrage senden", primary: { label: "Reparatur anfragen", href: "/reparatur" }, secondary: { label: "Leistungen", href: "/leistungen" } },
listTitle: "Kontaktinformationen",
list: [],
phone: "",
email: "",
address: "",
openingHours: "",
googleMapsLink: "",
socialMedia: [],
faq: [],
seo: seo("Kontakt", "Kontakt zu Funktechnik Schubert aufnehmen.", "/kontakt"),
updatedAt: now,
},
settings: {
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: seo("Funktechnik Schubert", "Service, Reparatur, Diagnose und Abgleich von Funktechnik, CB-Funk, Amateurfunk und Kommunikationstechnik.", "/"),
updatedAt: now,
},
};
export const contentFileNames = {
home: "home.json",
services: "services.json",
"radio-service": "radio-service.json",
about: "about.json",
contact: "contact.json",
settings: "settings.json",
} as const;

153
lib/content/service.ts Normal file
View file

@ -0,0 +1,153 @@
import { existsSync } from "fs";
import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from "fs/promises";
import path from "path";
import { storageDirectory } from "@/lib/runtime/config";
import { listMediaFiles } from "@/lib/admin/media";
import { contentFileNames, defaultContent } from "./defaults";
import type { ContentDocuments, ContentKey, ContentSummary } from "./types";
export const contentDirectory = path.join(storageDirectory, "content");
export const contentBackupDirectory = path.join(contentDirectory, "backups");
export const contentVersion = "0.4.0";
const contentKeys = Object.keys(contentFileNames) as ContentKey[];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function sanitizeString(value: string) {
return value
.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, "")
.replace(/javascript:/gi, "")
.replace(/[<>]/g, "")
.trim();
}
function sanitizeValue(value: unknown): unknown {
if (typeof value === "string") return sanitizeString(value);
if (Array.isArray(value)) return value.map(sanitizeValue);
if (!isRecord(value)) return value;
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, sanitizeValue(entry)]));
}
function mergeContent<T>(defaults: T, incoming: unknown): T {
if (Array.isArray(defaults)) return Array.isArray(incoming) ? sanitizeValue(incoming) as T : defaults;
if (!isRecord(defaults)) return sanitizeValue(incoming ?? defaults) as T;
if (!isRecord(incoming)) return defaults;
const merged: Record<string, unknown> = { ...defaults };
for (const [key, value] of Object.entries(defaults)) {
merged[key] = mergeContent(value, incoming[key]);
}
return sanitizeValue(merged) as T;
}
function filePathFor(key: ContentKey) {
return path.join(contentDirectory, contentFileNames[key]);
}
function backupStamp() {
const date = new Date();
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())}`;
}
async function pruneBackups(key: ContentKey) {
const prefix = `${key}.`;
const entries = await readdir(contentBackupDirectory).catch(() => []);
const backups = await Promise.all(entries
.filter((entry) => entry.startsWith(prefix) && entry.endsWith(".json"))
.map(async (entry) => {
const fullPath = path.join(contentBackupDirectory, entry);
const fileStat = await stat(fullPath);
return { entry, fullPath, modified: fileStat.mtimeMs };
}));
const obsolete = backups.sort((left, right) => right.modified - left.modified).slice(20);
await Promise.all(obsolete.map((entry) => unlink(entry.fullPath).catch(() => undefined)));
}
async function ensureContentDocument<K extends ContentKey>(key: K) {
await mkdir(contentDirectory, { recursive: true });
await mkdir(contentBackupDirectory, { recursive: true });
const target = filePathFor(key);
if (!existsSync(target)) {
await writeFile(target, `${JSON.stringify(defaultContent[key], null, 2)}\n`, "utf8");
}
}
export async function ensureContentStorage() {
await mkdir(contentDirectory, { recursive: true });
await mkdir(contentBackupDirectory, { recursive: true });
await Promise.all(contentKeys.map((key) => ensureContentDocument(key)));
}
export async function getContent<K extends ContentKey>(key: K): Promise<ContentDocuments[K]> {
await ensureContentDocument(key);
try {
const raw = await readFile(filePathFor(key), "utf8");
const parsed = JSON.parse(raw) as unknown;
return mergeContent(defaultContent[key], parsed);
} catch {
return defaultContent[key];
}
}
export async function getAllContent(): Promise<ContentDocuments> {
const entries = await Promise.all(contentKeys.map(async (key) => [key, await getContent(key)] as const));
return Object.fromEntries(entries) as ContentDocuments;
}
export async function saveContent<K extends ContentKey>(key: K, content: ContentDocuments[K]) {
await ensureContentDocument(key);
const current = filePathFor(key);
const backup = path.join(contentBackupDirectory, `${key}.${backupStamp()}.json`);
if (existsSync(current)) await rename(current, backup);
const next = mergeContent(defaultContent[key], {
...content,
updatedAt: new Date().toISOString(),
});
await writeFile(current, `${JSON.stringify(next, null, 2)}\n`, "utf8");
await pruneBackups(key);
return next;
}
export async function getContentSummary(): Promise<ContentSummary> {
await ensureContentStorage();
const documents = await Promise.all(contentKeys.map(async (key) => {
const content = await getContent(key);
return {
key,
fileName: contentFileNames[key],
title: "title" in content ? content.title : content.siteName,
updatedAt: content.updatedAt,
};
}));
const lastModified = documents
.map((document) => document.updatedAt)
.filter(Boolean)
.sort()
.at(-1) ?? null;
return {
version: contentVersion,
lastModified,
pageCount: documents.length,
documents,
};
}
export async function getContentSystemStatus() {
const [summary, media] = await Promise.all([getContentSummary(), listMediaFiles()]);
return {
...summary,
mediaCount: media.length,
};
}

128
lib/content/types.ts Normal file
View file

@ -0,0 +1,128 @@
export type SeoContent = {
metaTitle: string;
metaDescription: string;
keywords: string;
openGraphTitle: string;
openGraphDescription: string;
socialImage: string;
canonicalUrl: string;
};
export type LinkContent = {
label: string;
href: string;
};
export type TextBlockContent = {
title: string;
text: string;
};
export type FaqContent = {
question: string;
answer: string;
};
export type ServiceContent = {
title: string;
description: string;
icon: string;
sortOrder: number;
visible: boolean;
};
export type PageContent = {
eyebrow: string;
title: string;
subtitle: string;
heroText: string;
intro: string;
paragraphs: TextBlockContent[];
cta: {
text: string;
primary: LinkContent;
secondary: LinkContent;
};
listTitle: string;
list: string[];
faq: FaqContent[];
seo: SeoContent;
updatedAt: string;
};
export type HomeContent = PageContent & {
badges: string[];
features: TextBlockContent[];
services: TextBlockContent[];
promise: {
eyebrow: string;
title: string;
text: string;
bullets: string[];
button: LinkContent;
};
footerText: string;
};
export type ServicesContent = PageContent & {
services: ServiceContent[];
};
export type RadioServiceContent = PageContent & {
brands: string[];
symptoms: string[];
workflow: string[];
measurements: string[];
alignment: string;
repair: string;
};
export type AboutContent = PageContent & {
companyDescription: string;
workshopDescription: string;
philosophy: string;
};
export type ContactContent = PageContent & {
phone: string;
email: string;
address: string;
openingHours: string;
googleMapsLink: string;
socialMedia: LinkContent[];
};
export type SettingsContent = {
siteName: string;
logoUrl: string;
footerShortText: string;
copyright: string;
footerLinks: LinkContent[];
legalLinks: LinkContent[];
headerCta: LinkContent;
seo: SeoContent;
updatedAt: string;
};
export type ContentDocuments = {
home: HomeContent;
services: ServicesContent;
"radio-service": RadioServiceContent;
about: AboutContent;
contact: ContactContent;
settings: SettingsContent;
};
export type ContentKey = keyof ContentDocuments;
export type ContentSummary = {
version: string;
lastModified: string | null;
pageCount: number;
documents: Array<{
key: ContentKey;
fileName: string;
title: string;
updatedAt: string;
}>;
};

View file

@ -6,6 +6,8 @@ import { appVersion } from "./version";
export const dataDirectory = path.join(process.cwd(), "data");
export const storageDirectory = path.join(process.cwd(), "storage");
export const configDirectory = path.join(storageDirectory, "config");
export const contentDirectory = path.join(storageDirectory, "content");
export const contentBackupDirectory = path.join(contentDirectory, "backups");
export const uploadDirectory = path.join(storageDirectory, "uploads", "images");
export const legacyPublicUploadDirectory = path.join(process.cwd(), "public", "uploads", "images");
@ -20,6 +22,7 @@ export function isDockerEnvironment() {
export async function ensureRuntimeDirectories() {
await mkdir(dataDirectory, { recursive: true });
await mkdir(configDirectory, { recursive: true });
await mkdir(contentBackupDirectory, { recursive: true });
await mkdir(uploadDirectory, { recursive: true });
await migrateLegacyUploads();
}

View file

@ -1 +1 @@
export const appVersion = "0.3.0";
export const appVersion = "0.4.0";