import { useState, useEffect, useRef, useCallback, type FormEvent } from 'react'; import { Modal } from '../../components/Modal'; import api from '../../api/client'; import type { ExpertProject } from '../ExpertProfileTab'; import styles from '../ExpertProfileTab.module.css'; interface ProjectModalProps { isOpen: boolean; onClose: () => void; onSave: () => Promise; project: ExpertProject | null; apiBase?: string; } const MONTHS = [ { value: 1, label: 'Januar' }, { value: 2, label: 'Februar' }, { value: 3, label: 'März' }, { value: 4, label: 'April' }, { value: 5, label: 'Mai' }, { value: 6, label: 'Juni' }, { value: 7, label: 'Juli' }, { value: 8, label: 'August' }, { value: 9, label: 'September' }, { value: 10, label: 'Oktober' }, { value: 11, label: 'November' }, { value: 12, label: 'Dezember' }, ]; const COMPANY_SIZES = ['1-10', '11-50', '51-200', '201-500', '501-1000', '1001-5000', '5000+']; const INDUSTRIES = [ 'IT-Dienstleistung', 'Software-Entwicklung', 'Cloud & Hosting', 'Telekommunikation', 'Finanzdienstleistung', 'Versicherung', 'Gesundheitswesen', 'Pharma & Medizintechnik', 'Automobilindustrie', 'Maschinenbau', 'Energiewirtschaft', 'Logistik & Transport', 'Handel & E-Commerce', 'Medien & Unterhaltung', 'Bildung & Forschung', 'Öffentlicher Sektor', 'Beratung & Consulting', 'Luft- und Raumfahrt', 'Chemie & Werkstoffe', 'Sonstige', ]; const currentYear = new Date().getFullYear(); const YEARS = Array.from({ length: 40 }, (_, i) => currentYear - i); // ─── BulletEditor helpers (module-level, pure) ─────────────────────────────── /** * Wrap or unwrap the current selection with an inline formatting marker. * - With selection: wraps/unwraps (toggle) * - Without selection: inserts marker pair with cursor inside */ function blWrapFormat( ta: HTMLTextAreaElement, v: string, marker: string, onChange: (val: string) => void, ): void { const start = ta.selectionStart; const end = ta.selectionEnd; const mLen = marker.length; const selected = v.substring(start, end); if (selected.startsWith(marker) && selected.endsWith(marker) && selected.length > mLen * 2) { // Already wrapped → unwrap const inner = selected.substring(mLen, selected.length - mLen); onChange(v.substring(0, start) + inner + v.substring(end)); requestAnimationFrame(() => { ta.selectionStart = start; ta.selectionEnd = start + inner.length; ta.focus(); }); } else if (selected.length > 0) { // Wrap selection onChange(v.substring(0, start) + marker + selected + marker + v.substring(end)); requestAnimationFrame(() => { ta.selectionStart = start + mLen; ta.selectionEnd = end + mLen; ta.focus(); }); } else { // No selection: insert empty marker pair, cursor inside onChange(v.substring(0, start) + marker + marker + v.substring(start)); requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = start + mLen; ta.focus(); }); } } /** Parse the line at cursor position into its structural components */ function blLineAt(val: string, pos: number) { const s = val.lastIndexOf('\n', pos - 1) + 1; const eIdx = val.indexOf('\n', pos); const e = eIdx === -1 ? val.length : eIdx; const text = val.substring(s, e); const bm = text.match(/^( *)(• )([\s\S]*)$/); const nm = text.match(/^( *)(\d+)\. ([\s\S]*)$/); return { s, e, text, indent: bm?.[1] ?? nm?.[1] ?? (text.match(/^ */)?.[0] ?? ''), isBullet: !!bm, isNum: !!nm, num: nm ? parseInt(nm[2]) : 0, body: bm ? bm[3] : nm ? nm[3] : text.replace(/^ */, ''), }; } // ─── BulletEditor component ─────────────────────────────────────────────────── /** * Lightweight rich-text-like editor for task lists. * Supports bullet lists (• ), numbered lists (1. 2. 3.), * Tab/Shift+Tab indentation, and auto-continuation on Enter. * No external dependencies — plain textarea with smart keyboard handling. */ function BulletEditor({ value, onChange, placeholder, maxLength, rows = 6, }: { value: string; onChange: (val: string) => void; placeholder?: string; maxLength?: number; rows?: number; }) { const textareaRef = useRef(null); /** Replace the current line and reposition cursor */ function applyLineMutation( ta: HTMLTextAreaElement, v: string, s: number, e: number, newLine: string, newPos: number, ) { onChange(v.substring(0, s) + newLine + v.substring(e)); requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = newPos; ta.focus(); }); } // ── Toggle bullet list ────────────────────────────────────────────────────── const toggleBullet = useCallback(() => { const ta = textareaRef.current; if (!ta) return; const pos = ta.selectionStart; const v = ta.value; const { s, e, indent, isBullet, isNum, num, body } = blLineAt(v, pos); if (isBullet) { // Remove bullet applyLineMutation(ta, v, s, e, indent + body, Math.max(s + indent.length, pos - 2)); } else if (isNum) { // Convert numbered → bullet const oldNumLen = String(num).length + 2; // "N. " applyLineMutation(ta, v, s, e, indent + '• ' + body, Math.max(s + indent.length + 2, pos - oldNumLen + 2)); } else { // Add bullet applyLineMutation(ta, v, s, e, indent + '• ' + body, pos + 2); } }, [onChange]); // eslint-disable-line react-hooks/exhaustive-deps // ── Inline formatting: Bold / Italic / Underline ─────────────────────────── const wrapBold = useCallback(() => { const ta = textareaRef.current; if (ta) blWrapFormat(ta, ta.value, '**', onChange); }, [onChange]); const wrapItalic = useCallback(() => { const ta = textareaRef.current; if (ta) blWrapFormat(ta, ta.value, '*', onChange); }, [onChange]); const wrapUnderline = useCallback(() => { const ta = textareaRef.current; if (ta) blWrapFormat(ta, ta.value, '__', onChange); }, [onChange]); // ── Toggle numbered list ──────────────────────────────────────────────────── const toggleNumbered = useCallback(() => { const ta = textareaRef.current; if (!ta) return; const pos = ta.selectionStart; const v = ta.value; const { s, e, indent, isBullet, isNum, num, body } = blLineAt(v, pos); if (isNum) { // Remove numbered const oldNumLen = String(num).length + 2; applyLineMutation(ta, v, s, e, indent + body, Math.max(s + indent.length, pos - oldNumLen)); } else if (isBullet) { // Convert bullet → numbered applyLineMutation(ta, v, s, e, indent + '1. ' + body, s + indent.length + 3); } else { // Add numbered applyLineMutation(ta, v, s, e, indent + '1. ' + body, pos + 3); } }, [onChange]); // eslint-disable-line react-hooks/exhaustive-deps // ── Keyboard handler: Tab/Shift+Tab + Enter ───────────────────────────────── const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { const ta = textareaRef.current; if (!ta) return; const pos = ta.selectionStart; const v = ta.value; const info = blLineAt(v, pos); // Ctrl+B / Ctrl+I / Ctrl+U → inline formatting if ((e.ctrlKey || e.metaKey) && ['b', 'i', 'u'].includes(e.key)) { e.preventDefault(); if (e.key === 'b') blWrapFormat(ta, v, '**', onChange); else if (e.key === 'i') blWrapFormat(ta, v, '*', onChange); else if (e.key === 'u') blWrapFormat(ta, v, '__', onChange); return; } // Tab → indent (+2 spaces at line start) // Shift+Tab → outdent (-2 spaces from line start) if (e.key === 'Tab') { e.preventDefault(); if (e.shiftKey) { if (info.text.startsWith(' ')) { onChange(v.substring(0, info.s) + info.text.substring(2) + v.substring(info.e)); requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = Math.max(info.s, pos - 2); }); } } else { onChange(v.substring(0, info.s) + ' ' + v.substring(info.s)); requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = pos + 2; }); } return; } if (e.key !== 'Enter') return; const isEmptyItem = (info.isBullet || info.isNum) && info.body.trim() === ''; if (isEmptyItem) { // Empty list item → exit list, keep indentation only e.preventDefault(); onChange(v.substring(0, info.s) + info.indent + v.substring(info.e)); requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = info.s + info.indent.length; }); } else if (info.isBullet) { // Continue bullet on next line (preserving indentation) e.preventDefault(); const ins = '\n' + info.indent + '• '; onChange(v.substring(0, pos) + ins + v.substring(ta.selectionEnd)); requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = pos + ins.length; }); } else if (info.isNum) { // Continue numbered list with incremented number e.preventDefault(); const ins = '\n' + info.indent + String(info.num + 1) + '. '; onChange(v.substring(0, pos) + ins + v.substring(ta.selectionEnd)); requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = pos + ins.length; }); } }, [onChange], ); return (
Tab = einrücken · ⇧Tab = ausrücken