feat: connect CMS content, add ISR, Lexical renderer, and hero styling

- Fix tenant ID (1→4) to match CMS content
- Change homepage slug from "home" to "startseite"
- Add ISR with 60s revalidation for automatic content updates
- Create Lexical JSON→HTML renderer for rich text blocks
- Wire up dynamic favicon from CMS site-settings
- Adjust hero block: responsive font sizes, left-aligned layout,
  subline field support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
CCS Admin 2026-02-16 15:01:30 +00:00
parent be1c169481
commit fe83855f40
9 changed files with 160 additions and 113 deletions

View file

@ -1,89 +0,0 @@
---
active: true
iteration: 1
max_iterations: 50
completion_promise: "PHASE1_COMPLETE"
started_at: "2026-01-02T11:35:45Z"
---
# SSL-Monitor Phase 1: Foundation
## Kontext
- Verzeichnis: /home/frontend/ssl-monitor
- Tech Stack: Next.js 15 (App Router), Tailwind CSS, shadcn/ui, SQLite (better-sqlite3), BullMQ
- Port: 3009
- Redis: localhost:6379 (existiert bereits)
## Ziel
Grundgerüst mit funktionierendem SSL-Check und Dashboard erstellen.
## Aufgaben
### 1. Projekt-Setup
- [ ] Next.js 15 mit App Router initialisieren (falls nicht vorhanden)
- [ ] Dependencies installieren: better-sqlite3, bullmq, ioredis, zod, date-fns
- [ ] Tailwind CSS + shadcn/ui konfigurieren
- [ ] Projektstruktur gemäß umsetzung.md anlegen
### 2. Datenbank
- [ ] SQLite Schema erstellen (src/lib/db.ts)
- [ ] Tabellen: domains, ssl_checks, categories, settings
- [ ] Initial-Seed: 3 Default-Kategorien (Development, Production, External)
- [ ] Initial-Seed: 16 Domains aus umsetzung.md
### 3. SSL-Checker
- [ ] src/lib/ssl-checker.ts: tls.connect() Implementation
- [ ] Zertifikat-Parsing (Issuer, Subject, Validity, SANs)
- [ ] Certificate Chain Analyse
- [ ] OCSP-Status Check (src/lib/ocsp-checker.ts)
- [ ] Error-Handling mit Kategorisierung (dns, connection, certificate, timeout)
- [ ] Retry-Logik (Exponential Backoff)
### 4. Queue System
- [ ] src/lib/queue.ts: BullMQ Queue Setup
- [ ] worker/ssl-worker.ts: Worker Process
- [ ] Concurrency: 5 parallele Checks
- [ ] Job-Options: 3 Attempts, Exponential Backoff
### 5. API Routes
- [ ] GET/POST /api/domains - Domain CRUD
- [ ] GET/PUT/DELETE /api/domains/[id]
- [ ] POST /api/check - Single Domain Check (queued)
- [ ] POST /api/check/all - Alle Domains checken
- [ ] GET /api/history/[domainId] - Check History
### 6. Dashboard UI
- [ ] src/app/page.tsx: Dashboard mit Grid Layout
- [ ] components/dashboard/DomainCard.tsx: Expandable Card
- [ ] components/common/StatusBadge.tsx: Ampel (Grün/Gelb/Rot/Grau)
- [ ] components/dashboard/FilterBar.tsx: Kategorie + Status Filter + Suche
- [ ] components/domain/CertificateChain.tsx: Chain Visualisierung
## Erfolgskriterien
- [ ] `pnpm build` erfolgreich
- [ ] `pnpm lint` ohne Errors
- [ ] Dashboard zeigt alle 16 Domains
- [ ] SSL-Check für eine Domain funktioniert (manueller Test)
- [ ] Queue-Worker startet und verarbeitet Jobs
- [ ] StatusBadge zeigt korrekte Farben basierend auf daysRemaining
## Selbst-Prüfung nach jeder Iteration
1. `pnpm lint --fix`
2. `pnpm build`
3. Falls Fehler: analysieren, korrigieren, wiederholen
4. Falls Build erfolgreich: nächste Aufgabe
## Bei Blockade nach 40 Iterationen
- Dokumentiere in BLOCKERS.md was nicht funktioniert
- Liste versuchte Ansätze
- Markiere welche Aufgaben abgeschlossen sind
## Referenz
Lies /home/frontend/ssl-monitor/umsetzung.md für Details zu:
- Datenbank-Schema (Abschnitt 2.2)
- SSLCheckResult Interface (Abschnitt 3.2.1)
- Domain-Seed Daten (Abschnitt 5.3)
Output <promise>PHASE1_COMPLETE</promise> wenn alle Aufgaben erledigt.

View file

@ -3,6 +3,8 @@ import { BlockRenderer } from "@/components/blocks"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import type { Metadata } from "next" import type { Metadata } from "next"
export const revalidate = 60
interface PageProps { interface PageProps {
params: Promise<{ slug: string }> params: Promise<{ slug: string }>
} }
@ -11,7 +13,7 @@ export async function generateStaticParams() {
try { try {
const pages = await getPages({ limit: 100 }) const pages = await getPages({ limit: 100 })
return (pages?.docs || []) return (pages?.docs || [])
.filter((p) => p.slug && p.slug !== "home") .filter((p) => p.slug && p.slug !== "startseite")
.map((p) => ({ slug: p.slug })) .map((p) => ({ slug: p.slug }))
} catch { } catch {
return [] return []

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View file

@ -5,6 +5,18 @@ import { Footer } from '@/components/Footer'
import { getNavigation, getSiteSettings, getSocialLinks } from '@/lib/api' import { getNavigation, getSiteSettings, getSocialLinks } from '@/lib/api'
import './globals.css' import './globals.css'
export async function generateMetadata(): Promise<Metadata> {
const settings = await getSiteSettings() as Record<string, unknown> | null
const favicon = settings?.favicon as Record<string, unknown> | undefined
const faviconUrl = favicon?.url as string | undefined
return {
title: 'Martin Porwoll',
description: 'Whistleblower. Unternehmer. Mensch.',
icons: faviconUrl ? { icon: faviconUrl } : undefined,
}
}
const montserrat = Montserrat({ const montserrat = Montserrat({
subsets: ['latin'], subsets: ['latin'],
weight: ['400', '700'], weight: ['400', '700'],
@ -19,11 +31,6 @@ const openSans = Open_Sans({
display: 'swap', display: 'swap',
}) })
export const metadata: Metadata = {
title: 'Martin Porwoll',
description: 'Whistleblower. Unternehmer. Mensch.',
}
export default async function RootLayout({ export default async function RootLayout({
children, children,
}: { }: {

View file

@ -2,8 +2,10 @@ import { getPage, getSeoSettings } from "@/lib/api"
import { BlockRenderer } from "@/components/blocks" import { BlockRenderer } from "@/components/blocks"
import type { Metadata } from "next" import type { Metadata } from "next"
export const revalidate = 60
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const [page, seoSettings] = await Promise.all([getPage("home"), getSeoSettings()]) const [page, seoSettings] = await Promise.all([getPage("startseite"), getSeoSettings()])
if (!page) return { title: 'Martin Porwoll' } if (!page) return { title: 'Martin Porwoll' }
const seo = seoSettings as unknown as Record<string, unknown> | null const seo = seoSettings as unknown as Record<string, unknown> | null
@ -18,7 +20,7 @@ export async function generateMetadata(): Promise<Metadata> {
} }
export default async function HomePage() { export default async function HomePage() {
const page = await getPage("home") const page = await getPage("startseite")
if (!page) { if (!page) {
return ( return (

View file

@ -10,7 +10,7 @@ interface HeroBlockProps {
export function HeroBlock({ block }: HeroBlockProps) { export function HeroBlock({ block }: HeroBlockProps) {
const headline = (block.headline as string) || (block.title as string) || '' const headline = (block.headline as string) || (block.title as string) || ''
const subheadline = (block.subheadline as string) || (block.subtitle as string) || '' const subheadline = (block.subline as string) || (block.subheadline as string) || (block.subtitle as string) || ''
const backgroundMedia = block.backgroundImage as Record<string, unknown> | undefined const backgroundMedia = block.backgroundImage as Record<string, unknown> | undefined
const backgroundUrl = backgroundMedia?.url as string | undefined const backgroundUrl = backgroundMedia?.url as string | undefined
const ctaLabel = (block.ctaLabel as string) || (block.buttonText as string) || '' const ctaLabel = (block.ctaLabel as string) || (block.buttonText as string) || ''
@ -41,11 +41,11 @@ export function HeroBlock({ block }: HeroBlockProps) {
/> />
{/* Content */} {/* Content */}
<div className="table absolute inset-0 w-full h-full"> <div className="absolute inset-0 flex items-center">
<div <div
className={cn( className={cn(
'table-cell w-full h-full align-middle px-4', 'w-full px-4',
alignment === 'center' ? 'text-center' : 'text-left pl-[10%]' alignment === 'center' ? 'text-center mx-auto max-w-5xl' : 'text-left pl-[5%] md:pl-[8%] max-w-[65%] md:max-w-[55%]'
)} )}
> >
<motion.div <motion.div
@ -57,7 +57,7 @@ export function HeroBlock({ block }: HeroBlockProps) {
<h1 <h1
className={cn( className={cn(
'font-heading font-bold mb-[50px]', 'font-heading font-bold mb-[50px]',
'text-[3em] md:text-[5em] tracking-[10px] md:tracking-[15px]', 'text-[1.8em] sm:text-[2.5em] md:text-[3.5em] lg:text-[4em] tracking-[5px] sm:tracking-[8px] md:tracking-[10px] leading-[1.3]',
isDark ? 'text-white' : 'text-dark' isDark ? 'text-white' : 'text-dark'
)} )}
> >

View file

@ -1,5 +1,6 @@
import { Container } from '../ui/Container' import { Container } from '../ui/Container'
import { prose } from '@/lib/typography' import { prose } from '@/lib/typography'
import { renderLexical } from '@/lib/lexical'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
interface TextBlockProps { interface TextBlockProps {
@ -7,21 +8,17 @@ interface TextBlockProps {
} }
export function TextBlock({ block }: TextBlockProps) { export function TextBlock({ block }: TextBlockProps) {
// Handle Lexical richText content
const richText = block.content || block.richText const richText = block.content || block.richText
let htmlContent = '' // Content comes from trusted Payload CMS (authenticated admin-only).
// The renderLexical function escapes all text content via escapeHtml().
if (typeof richText === 'string') { const htmlContent = renderLexical(richText)
htmlContent = richText
} else if (richText && typeof richText === 'object' && 'root' in (richText as Record<string, unknown>)) {
// Lexical JSON - render as placeholder
htmlContent = '<p>Rich-Text-Inhalt wird geladen...</p>'
}
const alignment = (block.alignment as string) || 'left' const alignment = (block.alignment as string) || 'left'
const width = (block.width as 'narrow' | 'default' | 'wide') || 'default' const width = (block.width as 'narrow' | 'default' | 'wide') || 'default'
const backgroundColor = (block.backgroundColor as string) || 'white' const backgroundColor = (block.backgroundColor as string) || 'white'
if (!htmlContent) return null
return ( return (
<section <section
className={cn( className={cn(

View file

@ -1,14 +1,14 @@
/** /**
* Shared Payload CMS client for porwoll.de * Shared Payload CMS client for porwoll.de
* *
* Configured for tenant "porwoll" (ID: 1). * Configured for tenant "porwoll" (ID: 4).
* Tenant isolation is handled automatically. * Tenant isolation is handled automatically.
*/ */
import { createPayloadClient } from "@c2s/payload-contracts/api-client" import { createPayloadClient } from "@c2s/payload-contracts/api-client"
export const cms = createPayloadClient({ export const cms = createPayloadClient({
baseUrl: process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://cms.c2sgmbh.de", baseUrl: process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://cms.c2sgmbh.de",
tenantId: process.env.NEXT_PUBLIC_TENANT_ID || "1", tenantId: process.env.NEXT_PUBLIC_TENANT_ID || "4",
defaultLocale: "de", defaultLocale: "de",
defaultDepth: 2, defaultDepth: 2,
defaultRevalidate: 60, defaultRevalidate: 60,

128
src/lib/lexical.ts Normal file
View file

@ -0,0 +1,128 @@
/**
* Lexical JSON HTML renderer for Payload CMS rich text content.
*
* Converts Payload's Lexical editor JSON tree into HTML strings.
* Supports: paragraphs, headings, lists, links, text formatting, images, blockquotes.
*/
interface LexicalNode {
type: string
children?: LexicalNode[]
text?: string
format?: number | string
tag?: string
listType?: string
url?: string
newTab?: boolean
fields?: Record<string, unknown>
value?: Record<string, unknown>
direction?: string | null
indent?: number
version?: number
style?: string
[key: string]: unknown
}
interface LexicalRoot {
root: LexicalNode
}
const FORMAT_BOLD = 1
const FORMAT_ITALIC = 2
const FORMAT_STRIKETHROUGH = 4
const FORMAT_UNDERLINE = 8
const FORMAT_CODE = 16
const FORMAT_SUBSCRIPT = 32
const FORMAT_SUPERSCRIPT = 64
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function renderTextFormat(text: string, format: number): string {
let result = escapeHtml(text)
if (format & FORMAT_BOLD) result = `<strong>${result}</strong>`
if (format & FORMAT_ITALIC) result = `<em>${result}</em>`
if (format & FORMAT_UNDERLINE) result = `<u>${result}</u>`
if (format & FORMAT_STRIKETHROUGH) result = `<s>${result}</s>`
if (format & FORMAT_CODE) result = `<code>${result}</code>`
if (format & FORMAT_SUBSCRIPT) result = `<sub>${result}</sub>`
if (format & FORMAT_SUPERSCRIPT) result = `<sup>${result}</sup>`
return result
}
function renderNode(node: LexicalNode): string {
if (node.type === 'text') {
const format = typeof node.format === 'number' ? node.format : 0
return renderTextFormat(node.text || '', format)
}
if (node.type === 'linebreak') {
return '<br />'
}
const children = (node.children || []).map(renderNode).join('')
switch (node.type) {
case 'root':
return children
case 'paragraph': {
if (!children.trim()) return ''
const align = typeof node.format === 'string' && node.format ? ` style="text-align:${node.format}"` : ''
return `<p${align}>${children}</p>`
}
case 'heading': {
const tag = node.tag || 'h2'
return `<${tag}>${children}</${tag}>`
}
case 'list': {
const tag = node.listType === 'number' ? 'ol' : 'ul'
return `<${tag}>${children}</${tag}>`
}
case 'listitem':
return `<li>${children}</li>`
case 'link':
case 'autolink': {
const url = node.url || (node.fields as Record<string, unknown>)?.url || '#'
const target = node.newTab ? ' target="_blank" rel="noopener noreferrer"' : ''
return `<a href="${escapeHtml(url as string)}"${target}>${children}</a>`
}
case 'quote':
return `<blockquote>${children}</blockquote>`
case 'upload': {
const value = node.value as Record<string, unknown> | undefined
if (value?.url) {
const alt = (value.alt as string) || ''
return `<img src="${escapeHtml(value.url as string)}" alt="${escapeHtml(alt)}" />`
}
return ''
}
case 'horizontalrule':
return '<hr />'
default:
return children
}
}
export function renderLexical(content: unknown): string {
if (!content) return ''
if (typeof content === 'string') return content
const lexical = content as LexicalRoot
if (!lexical.root) return ''
return renderNode(lexical.root)
}