diff --git a/.claude/ralph-loop.local.md b/.claude/ralph-loop.local.md deleted file mode 100644 index 42fc15b..0000000 --- a/.claude/ralph-loop.local.md +++ /dev/null @@ -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 PHASE1_COMPLETE wenn alle Aufgaben erledigt. - diff --git a/src/app/[slug]/page.tsx b/src/app/[slug]/page.tsx index 216544e..0078dc1 100644 --- a/src/app/[slug]/page.tsx +++ b/src/app/[slug]/page.tsx @@ -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 [] diff --git a/src/app/favicon.ico b/src/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/src/app/favicon.ico and /dev/null differ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c918e0a..1b205c4 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,18 @@ import { Footer } from '@/components/Footer' import { getNavigation, getSiteSettings, getSocialLinks } from '@/lib/api' import './globals.css' +export async function generateMetadata(): Promise { + const settings = await getSiteSettings() as Record | null + const favicon = settings?.favicon as Record | 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, }: { diff --git a/src/app/page.tsx b/src/app/page.tsx index a07a3ab..b6ce715 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 { - 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 | null @@ -18,7 +20,7 @@ export async function generateMetadata(): Promise { } export default async function HomePage() { - const page = await getPage("home") + const page = await getPage("startseite") if (!page) { return ( diff --git a/src/components/blocks/HeroBlock.tsx b/src/components/blocks/HeroBlock.tsx index f687d98..849fd42 100644 --- a/src/components/blocks/HeroBlock.tsx +++ b/src/components/blocks/HeroBlock.tsx @@ -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 | 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 */} -
+
diff --git a/src/components/blocks/TextBlock.tsx b/src/components/blocks/TextBlock.tsx index 11a5eed..4a52704 100644 --- a/src/components/blocks/TextBlock.tsx +++ b/src/components/blocks/TextBlock.tsx @@ -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)) { - // Lexical JSON - render as placeholder - htmlContent = '

Rich-Text-Inhalt wird geladen...

' - } + // 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 (
+ value?: Record + 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, '"') +} + +function renderTextFormat(text: string, format: number): string { + let result = escapeHtml(text) + if (format & FORMAT_BOLD) result = `${result}` + if (format & FORMAT_ITALIC) result = `${result}` + if (format & FORMAT_UNDERLINE) result = `${result}` + if (format & FORMAT_STRIKETHROUGH) result = `${result}` + if (format & FORMAT_CODE) result = `${result}` + if (format & FORMAT_SUBSCRIPT) result = `${result}` + if (format & FORMAT_SUPERSCRIPT) result = `${result}` + 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 '
' + } + + 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 `${children}

` + } + + case 'heading': { + const tag = node.tag || 'h2' + return `<${tag}>${children}` + } + + case 'list': { + const tag = node.listType === 'number' ? 'ol' : 'ul' + return `<${tag}>${children}` + } + + case 'listitem': + return `
  • ${children}
  • ` + + case 'link': + case 'autolink': { + const url = node.url || (node.fields as Record)?.url || '#' + const target = node.newTab ? ' target="_blank" rel="noopener noreferrer"' : '' + return `${children}` + } + + case 'quote': + return `
    ${children}
    ` + + case 'upload': { + const value = node.value as Record | undefined + if (value?.url) { + const alt = (value.alt as string) || '' + return `${escapeHtml(alt)}` + } + return '' + } + + case 'horizontalrule': + return '
    ' + + 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) +}