All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 36s
CI/CD / test-python-backend-compliance (push) Successful in 33s
CI/CD / test-python-document-crawler (push) Successful in 24s
CI/CD / test-python-dsms-gateway (push) Successful in 21s
CI/CD / validate-canonical-controls (push) Successful in 13s
CI/CD / Deploy (push) Successful in 2s
Phase 1: Fix completeness gates G23 (require verified/rejected mitigations) and G09 (audit trail check) Phase 2: LLM-based tech-file section generation with 19 German prompts and RAG enrichment Phase 3: Multi-format document export (PDF/Excel/DOCX/Markdown/JSON) Phase 4: Company profile → IACE data flow with auto component/classification creation Phase 5: TipTap WYSIWYG editor replacing textarea for tech-file sections Phase 6: User journey tests, developer portal API reference, updated documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
273 lines
9.9 KiB
TypeScript
273 lines
9.9 KiB
TypeScript
'use client'
|
|
|
|
import React, { useCallback, useEffect, useRef } from 'react'
|
|
import { useEditor, EditorContent, type Editor } from '@tiptap/react'
|
|
import StarterKit from '@tiptap/starter-kit'
|
|
import Table from '@tiptap/extension-table'
|
|
import TableRow from '@tiptap/extension-table-row'
|
|
import TableHeader from '@tiptap/extension-table-header'
|
|
import TableCell from '@tiptap/extension-table-cell'
|
|
import Image from '@tiptap/extension-image'
|
|
|
|
interface TechFileEditorProps {
|
|
content: string
|
|
onSave: (content: string) => void
|
|
readOnly?: boolean
|
|
}
|
|
|
|
function normalizeContent(content: string): string {
|
|
if (!content) return '<p></p>'
|
|
const trimmed = content.trim()
|
|
// If it looks like JSON array or has no HTML tags, wrap in <p>
|
|
if (trimmed.startsWith('[') || !/<[a-z][\s\S]*>/i.test(trimmed)) {
|
|
return `<p>${trimmed.replace(/\n/g, '</p><p>')}</p>`
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
interface ToolbarButtonProps {
|
|
onClick: () => void
|
|
isActive?: boolean
|
|
disabled?: boolean
|
|
title: string
|
|
children: React.ReactNode
|
|
}
|
|
|
|
function ToolbarButton({ onClick, isActive, disabled, title, children }: ToolbarButtonProps) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
title={title}
|
|
className={`p-1.5 rounded text-sm font-medium transition-colors ${
|
|
isActive
|
|
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300'
|
|
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700'
|
|
} disabled:opacity-40 disabled:cursor-not-allowed`}
|
|
>
|
|
{children}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
export function TechFileEditor({ content, onSave, readOnly = false }: TechFileEditorProps) {
|
|
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const onSaveRef = useRef(onSave)
|
|
onSaveRef.current = onSave
|
|
|
|
const debouncedSave = useCallback((html: string) => {
|
|
if (debounceTimer.current) {
|
|
clearTimeout(debounceTimer.current)
|
|
}
|
|
debounceTimer.current = setTimeout(() => {
|
|
onSaveRef.current(html)
|
|
}, 3000)
|
|
}, [])
|
|
|
|
const editor = useEditor({
|
|
extensions: [
|
|
StarterKit.configure({
|
|
heading: { levels: [2, 3, 4] },
|
|
}),
|
|
Table.configure({
|
|
resizable: true,
|
|
HTMLAttributes: { class: 'border-collapse border border-gray-300' },
|
|
}),
|
|
TableRow,
|
|
TableHeader,
|
|
TableCell,
|
|
Image.configure({
|
|
HTMLAttributes: { class: 'max-w-full rounded' },
|
|
}),
|
|
],
|
|
content: normalizeContent(content),
|
|
editable: !readOnly,
|
|
onUpdate: ({ editor: ed }: { editor: Editor }) => {
|
|
if (!readOnly) {
|
|
debouncedSave(ed.getHTML())
|
|
}
|
|
},
|
|
editorProps: {
|
|
attributes: {
|
|
class: 'prose prose-sm max-w-none dark:prose-invert focus:outline-none min-h-[300px] px-4 py-3',
|
|
},
|
|
},
|
|
})
|
|
|
|
// Update content when parent prop changes
|
|
useEffect(() => {
|
|
if (editor && content) {
|
|
const normalized = normalizeContent(content)
|
|
const currentHTML = editor.getHTML()
|
|
if (normalized !== currentHTML) {
|
|
editor.commands.setContent(normalized)
|
|
}
|
|
}
|
|
}, [content, editor])
|
|
|
|
// Update editable state when readOnly changes
|
|
useEffect(() => {
|
|
if (editor) {
|
|
editor.setEditable(!readOnly)
|
|
}
|
|
}, [readOnly, editor])
|
|
|
|
// Cleanup debounce timer
|
|
useEffect(() => {
|
|
return () => {
|
|
if (debounceTimer.current) {
|
|
clearTimeout(debounceTimer.current)
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
if (!editor) {
|
|
return (
|
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 min-h-[300px] flex items-center justify-center">
|
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-600" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
{/* Toolbar */}
|
|
{!readOnly && (
|
|
<div className="flex flex-wrap items-center gap-0.5 px-2 py-1.5 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
|
{/* Text formatting */}
|
|
<ToolbarButton
|
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
|
isActive={editor.isActive('bold')}
|
|
title="Fett (Ctrl+B)"
|
|
>
|
|
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z" />
|
|
</svg>
|
|
</ToolbarButton>
|
|
<ToolbarButton
|
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
|
isActive={editor.isActive('italic')}
|
|
title="Kursiv (Ctrl+I)"
|
|
>
|
|
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z" />
|
|
</svg>
|
|
</ToolbarButton>
|
|
|
|
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
|
|
|
|
{/* Headings */}
|
|
<ToolbarButton
|
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
|
isActive={editor.isActive('heading', { level: 2 })}
|
|
title="Ueberschrift 2"
|
|
>
|
|
<span className="text-xs font-bold">H2</span>
|
|
</ToolbarButton>
|
|
<ToolbarButton
|
|
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
|
isActive={editor.isActive('heading', { level: 3 })}
|
|
title="Ueberschrift 3"
|
|
>
|
|
<span className="text-xs font-bold">H3</span>
|
|
</ToolbarButton>
|
|
<ToolbarButton
|
|
onClick={() => editor.chain().focus().toggleHeading({ level: 4 }).run()}
|
|
isActive={editor.isActive('heading', { level: 4 })}
|
|
title="Ueberschrift 4"
|
|
>
|
|
<span className="text-xs font-bold">H4</span>
|
|
</ToolbarButton>
|
|
|
|
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
|
|
|
|
{/* Lists */}
|
|
<ToolbarButton
|
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
isActive={editor.isActive('bulletList')}
|
|
title="Aufzaehlung"
|
|
>
|
|
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z" />
|
|
</svg>
|
|
</ToolbarButton>
|
|
<ToolbarButton
|
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
|
isActive={editor.isActive('orderedList')}
|
|
title="Nummerierte Liste"
|
|
>
|
|
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z" />
|
|
</svg>
|
|
</ToolbarButton>
|
|
|
|
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
|
|
|
|
{/* Table */}
|
|
<ToolbarButton
|
|
onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}
|
|
title="Tabelle einfuegen (3x3)"
|
|
>
|
|
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
<line x1="3" y1="9" x2="21" y2="9" />
|
|
<line x1="3" y1="15" x2="21" y2="15" />
|
|
<line x1="9" y1="3" x2="9" y2="21" />
|
|
<line x1="15" y1="3" x2="15" y2="21" />
|
|
</svg>
|
|
</ToolbarButton>
|
|
|
|
{/* Blockquote */}
|
|
<ToolbarButton
|
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
|
isActive={editor.isActive('blockquote')}
|
|
title="Zitat"
|
|
>
|
|
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" />
|
|
</svg>
|
|
</ToolbarButton>
|
|
|
|
{/* Code Block */}
|
|
<ToolbarButton
|
|
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
|
isActive={editor.isActive('codeBlock')}
|
|
title="Code-Block"
|
|
>
|
|
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<polyline points="16,18 22,12 16,6" />
|
|
<polyline points="8,6 2,12 8,18" />
|
|
</svg>
|
|
</ToolbarButton>
|
|
|
|
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
|
|
|
|
{/* Undo / Redo */}
|
|
<ToolbarButton
|
|
onClick={() => editor.chain().focus().undo().run()}
|
|
disabled={!editor.can().undo()}
|
|
title="Rueckgaengig (Ctrl+Z)"
|
|
>
|
|
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z" />
|
|
</svg>
|
|
</ToolbarButton>
|
|
<ToolbarButton
|
|
onClick={() => editor.chain().focus().redo().run()}
|
|
disabled={!editor.can().redo()}
|
|
title="Wiederholen (Ctrl+Shift+Z)"
|
|
>
|
|
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M18.4 10.6C16.55 8.99 14.15 8 11.5 8c-4.65 0-8.58 3.03-9.96 7.22L3.9 16c1.05-3.19 4.05-5.5 7.6-5.5 1.95 0 3.73.72 5.12 1.88L13 16h9V7l-3.6 3.6z" />
|
|
</svg>
|
|
</ToolbarButton>
|
|
</div>
|
|
)}
|
|
|
|
{/* Editor Content */}
|
|
<EditorContent editor={editor} />
|
|
</div>
|
|
)
|
|
}
|