mirror of
https://github.com/complexcaresolutions/frontend.porwoll.de.git
synced 2026-03-17 16:23:41 +00:00
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:
parent
be1c169481
commit
fe83855f40
9 changed files with 160 additions and 113 deletions
|
|
@ -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.
|
||||
|
||||
|
|
@ -3,6 +3,8 @@ import { BlockRenderer } from "@/components/blocks"
|
|||
import { notFound } from "next/navigation"
|
||||
import type { Metadata } from "next"
|
||||
|
||||
export const revalidate = 60
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ slug: string }>
|
||||
}
|
||||
|
|
@ -11,7 +13,7 @@ export async function generateStaticParams() {
|
|||
try {
|
||||
const pages = await getPages({ limit: 100 })
|
||||
return (pages?.docs || [])
|
||||
.filter((p) => p.slug && p.slug !== "home")
|
||||
.filter((p) => p.slug && p.slug !== "startseite")
|
||||
.map((p) => ({ slug: p.slug }))
|
||||
} catch {
|
||||
return []
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
|
|
@ -5,6 +5,18 @@ import { Footer } from '@/components/Footer'
|
|||
import { getNavigation, getSiteSettings, getSocialLinks } from '@/lib/api'
|
||||
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({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '700'],
|
||||
|
|
@ -19,11 +31,6 @@ const openSans = Open_Sans({
|
|||
display: 'swap',
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Martin Porwoll',
|
||||
description: 'Whistleblower. Unternehmer. Mensch.',
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ import { getPage, getSeoSettings } from "@/lib/api"
|
|||
import { BlockRenderer } from "@/components/blocks"
|
||||
import type { Metadata } from "next"
|
||||
|
||||
export const revalidate = 60
|
||||
|
||||
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' }
|
||||
|
||||
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() {
|
||||
const page = await getPage("home")
|
||||
const page = await getPage("startseite")
|
||||
|
||||
if (!page) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ interface HeroBlockProps {
|
|||
|
||||
export function HeroBlock({ block }: HeroBlockProps) {
|
||||
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 backgroundUrl = backgroundMedia?.url as string | undefined
|
||||
const ctaLabel = (block.ctaLabel as string) || (block.buttonText as string) || ''
|
||||
|
|
@ -41,11 +41,11 @@ export function HeroBlock({ block }: HeroBlockProps) {
|
|||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="table absolute inset-0 w-full h-full">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div
|
||||
className={cn(
|
||||
'table-cell w-full h-full align-middle px-4',
|
||||
alignment === 'center' ? 'text-center' : 'text-left pl-[10%]'
|
||||
'w-full px-4',
|
||||
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
|
||||
|
|
@ -57,7 +57,7 @@ export function HeroBlock({ block }: HeroBlockProps) {
|
|||
<h1
|
||||
className={cn(
|
||||
'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'
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Container } from '../ui/Container'
|
||||
import { prose } from '@/lib/typography'
|
||||
import { renderLexical } from '@/lib/lexical'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TextBlockProps {
|
||||
|
|
@ -7,21 +8,17 @@ interface TextBlockProps {
|
|||
}
|
||||
|
||||
export function TextBlock({ block }: TextBlockProps) {
|
||||
// Handle Lexical richText content
|
||||
const richText = block.content || block.richText
|
||||
let htmlContent = ''
|
||||
|
||||
if (typeof richText === 'string') {
|
||||
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>'
|
||||
}
|
||||
// Content comes from trusted Payload CMS (authenticated admin-only).
|
||||
// The renderLexical function escapes all text content via escapeHtml().
|
||||
const htmlContent = renderLexical(richText)
|
||||
|
||||
const alignment = (block.alignment as string) || 'left'
|
||||
const width = (block.width as 'narrow' | 'default' | 'wide') || 'default'
|
||||
const backgroundColor = (block.backgroundColor as string) || 'white'
|
||||
|
||||
if (!htmlContent) return null
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
/**
|
||||
* Shared Payload CMS client for porwoll.de
|
||||
*
|
||||
* Configured for tenant "porwoll" (ID: 1).
|
||||
* Configured for tenant "porwoll" (ID: 4).
|
||||
* Tenant isolation is handled automatically.
|
||||
*/
|
||||
import { createPayloadClient } from "@c2s/payload-contracts/api-client"
|
||||
|
||||
export const cms = createPayloadClient({
|
||||
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",
|
||||
defaultDepth: 2,
|
||||
defaultRevalidate: 60,
|
||||
|
|
|
|||
128
src/lib/lexical.ts
Normal file
128
src/lib/lexical.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
Loading…
Reference in a new issue