feat(frontend): bullet-editor B/I/U-Formatierung + Aufgaben-Anzeige in Projektliste

BulletEditor (ProjectModal.tsx):
- blWrapFormat(): wrap/unwrap Selektion mit Marker-Paar (**/*/__),
  leere Selektion → leeres Marker-Paar mit Cursor dazwischen
- Buttons: B (Fett **), I (Kursiv *), U (Unterstrichen __)
- Shortcuts: Strg/Cmd + B/I/U in handleKeyDown

ProjectsSection.tsx:
- renderInline(): regex-basierter Inline-Markdown-Renderer ohne
  dangerouslySetInnerHTML — wandelt **bold**, *italic*, __underline__ um
- RichText-Komponente: rendert Aufgaben-Text mit Bullets, Nummernlisten,
  Einrueckung und Inline-Formatierung
- Projektliste zeigt Aufgaben unterhalb der Taetigkeitszeile an
  (nur wenn vorhanden, mit border-top als optischem Trenner)
- Layout-Anpassung: entryItemExpanded + entryItemRow fuer vertikales Layout

CSS: bulletBtnBold/Italic/Underline, entryItemExpanded/Row,
entryTasks, richText/Line/Bullet/Num/Blank

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-13 20:47:07 +01:00
parent 4f141b94e5
commit b872b7e708
4 changed files with 285 additions and 28 deletions

View file

@ -6,6 +6,28 @@
---
### Aenderungen 2026-03-13 (15): Experten-Profil BulletEditor Fett/Kursiv/Unterstrichen + Aufgaben-Anzeige
#### Frontend
- `profile/sections/ProjectModal.tsx` — BulletEditor: Inline-Formatierungen
- Neue Hilfsfunktion `blWrapFormat()` auf Modul-Ebene: Wraps/Unwraps markierten Text mit Marker-Paar; kein Text markiert → Marker-Paar einsetzen (Cursor dazwischen); Text bereits gewrapped → aushakenlen
- Toolbar: Neuer Button "B" (Fett, `**text**`), "I" (Kursiv, `*text*`), "U" (Unterstrichen, `__text__`)
- Keyboard-Shortcuts: Strg+B, Strg+I, Strg+U (Mac: Cmd+B/I/U)
- `profile/sections/ProjectsSection.tsx` — Aufgaben-Anzeige mit Markdown-Renderer
- Neue Funktion `renderInline()`: wandelt **bold**, *italic*, __underline__ Marker in React-Elemente um (regex-basiert, kein XSS, kein dangerouslySetInnerHTML)
- Neue Komponente `<RichText text={...}>`: Rendert Aufgaben-Text Zeile fuer Zeile mit Bullet-Punkt-Layout, nummerierter Liste, Einrueckung und Inline-Formatierung
- Projekt-Listenelement: zeigt Aufgaben-Text unterhalb von Taetikeit/Firma an (separator border-top) — nur wenn Aufgaben vorhanden
- Neues Layout `entryItemExpanded + entryItemRow` (column statt row wenn Aufgaben vorhanden)
- `profile/ExpertProfileTab.module.css` — Neue Klassen: `.bulletBtnBold`, `.bulletBtnItalic`, `.bulletBtnUnderline`; `.entryItemExpanded`, `.entryItemRow`, `.entryTasks`; `.richText`, `.richTextLine`, `.richTextBullet`, `.richTextNum`, `.richTextBlank`
#### TypeScript
- `npx tsc --noEmit` in packages/frontend: 0 Fehler
#### Deployment-Hinweis (Schritt 15)
- Rebuild + Restart: nur frontend
---
### Aenderungen 2026-03-13 (14): Experten-Profil BulletEditor mit nummerierter Liste + Tab-Einrueckung
#### Frontend

View file

@ -497,6 +497,77 @@
white-space: nowrap;
}
/* B / I / U formatting buttons */
.bulletBtnBold {
font-weight: 700;
font-size: 0.875rem;
min-width: 1.75rem;
text-align: center;
}
.bulletBtnItalic {
font-style: italic;
font-size: 0.875rem;
min-width: 1.75rem;
text-align: center;
}
.bulletBtnUnderline {
text-decoration: underline;
font-size: 0.875rem;
min-width: 1.75rem;
text-align: center;
}
/* === Project list item with expanded tasks === */
.entryItemExpanded {
flex-direction: column !important;
align-items: stretch !important;
gap: 0.5rem;
}
.entryItemRow {
display: flex;
align-items: center;
justify-content: space-between;
}
.entryTasks {
padding-top: 0.5rem;
border-top: 1px solid var(--color-border);
font-size: 0.875rem;
color: var(--color-text);
line-height: 1.6;
}
/* === RichText renderer === */
.richText {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.richTextLine {
display: flex;
gap: 0.375rem;
}
.richTextBullet {
flex-shrink: 0;
color: var(--color-text-muted);
}
.richTextNum {
flex-shrink: 0;
min-width: 1.375rem;
text-align: right;
color: var(--color-text-muted);
}
.richTextBlank {
height: 0.375rem;
}
.checkboxRow {
display: flex;
align-items: center;

View file

@ -36,6 +36,49 @@ 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;
@ -116,6 +159,22 @@ function BulletEditor({
}
}, [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;
@ -146,6 +205,15 @@ function BulletEditor({
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') {
@ -218,6 +286,31 @@ function BulletEditor({
1. Liste
</button>
<span className={styles.bulletToolbarSep} />
<button
type="button"
className={`${styles.bulletBtn} ${styles.bulletBtnBold}`}
onClick={wrapBold}
title="Fett (Strg+B) — Text markieren, dann B drücken"
>
B
</button>
<button
type="button"
className={`${styles.bulletBtn} ${styles.bulletBtnItalic}`}
onClick={wrapItalic}
title="Kursiv (Strg+I) — Text markieren, dann I drücken"
>
I
</button>
<button
type="button"
className={`${styles.bulletBtn} ${styles.bulletBtnUnderline}`}
onClick={wrapUnderline}
title="Unterstrichen (Strg+U) — Text markieren, dann U drücken"
>
U
</button>
<span className={styles.bulletToolbarSep} />
<span className={styles.bulletToolbarHint}>Tab = einrücken · Tab = ausrücken</span>
</div>
<textarea

View file

@ -1,9 +1,70 @@
import { useState } from 'react';
import { useState, type ReactNode } from 'react';
import type { ExpertProject } from '../ExpertProfileTab';
import { ProjectModal } from './ProjectModal';
import styles from '../ExpertProfileTab.module.css';
import api from '../../api/client';
// ─── Inline markdown renderer ─────────────────────────────────────────────────
/** Convert **bold**, *italic*, __underline__ markers to React elements */
function renderInline(text: string): ReactNode {
// Pattern: **bold** first (must precede *italic* to avoid false matches)
const pattern = /(\*\*([^*\n]+)\*\*)|(\*([^*\n]+)\*)|(__([^_\n]+)__)/g;
const parts: ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
let key = 0;
while ((match = pattern.exec(text)) !== null) {
if (match.index > lastIndex) parts.push(text.substring(lastIndex, match.index));
if (match[1]) parts.push(<strong key={key++}>{match[2]}</strong>);
else if (match[3]) parts.push(<em key={key++}>{match[4]}</em>);
else if (match[5]) parts.push(<u key={key++}>{match[6]}</u>);
lastIndex = pattern.lastIndex;
}
if (lastIndex < text.length) parts.push(text.substring(lastIndex));
return <>{parts}</>;
}
/** Render the full tasks string: bullets, numbered lists, indentation, inline formatting */
function RichText({ text }: { text: string }) {
const lines = text.split('\n');
return (
<div className={styles.richText}>
{lines.map((line, i) => {
const bm = line.match(/^( *)(• )(.*)$/);
const nm = line.match(/^( *)(\d+)\. (.*)$/);
if (bm) {
return (
<div key={i} className={styles.richTextLine} style={{ paddingLeft: `${bm[1].length * 0.875}rem` }}>
<span className={styles.richTextBullet}></span>
<span>{renderInline(bm[3])}</span>
</div>
);
}
if (nm) {
return (
<div key={i} className={styles.richTextLine} style={{ paddingLeft: `${nm[1].length * 0.875}rem` }}>
<span className={styles.richTextNum}>{nm[2]}.</span>
<span>{renderInline(nm[3])}</span>
</div>
);
}
if (!line.trim()) {
return <div key={i} className={styles.richTextBlank} />;
}
const im = line.match(/^( *)(.*)/);
return (
<div key={i} style={{ paddingLeft: `${(im?.[1]?.length ?? 0) * 0.875}rem` }}>
{renderInline(im?.[2] ?? line)}
</div>
);
})}
</div>
);
}
interface ProjectsSectionProps {
projects: ExpertProject[];
onUpdate: () => Promise<void>;
@ -71,34 +132,44 @@ export function ProjectsSection({ projects, onUpdate }: ProjectsSectionProps) {
{projects.length > 0 ? (
<div className={styles.entryList}>
{projects.map((project) => (
<div key={project.id} className={styles.entryItem}>
<div className={styles.entryInfo}>
<span className={styles.entrySecondary}>{formatPeriod(project)}</span>
<span className={styles.entryPrimary}>{project.role}</span>
{project.company && (
<span className={styles.entrySecondary}>{project.company}</span>
)}
</div>
<div className={styles.entryActions}>
<button
type="button"
className={styles.btnIcon}
onClick={() => handleEdit(project)}
disabled={loading}
title="Bearbeiten"
>
</button>
<button
type="button"
className={`${styles.btnIcon} ${styles.btnIconDanger}`}
onClick={() => handleDelete(project.id)}
disabled={loading}
title="Löschen"
>
🗑
</button>
<div
key={project.id}
className={`${styles.entryItem} ${project.tasks ? styles.entryItemExpanded : ''}`}
>
<div className={styles.entryItemRow}>
<div className={styles.entryInfo}>
<span className={styles.entrySecondary}>{formatPeriod(project)}</span>
<span className={styles.entryPrimary}>{project.role}</span>
{project.company && (
<span className={styles.entrySecondary}>{project.company}</span>
)}
</div>
<div className={styles.entryActions}>
<button
type="button"
className={styles.btnIcon}
onClick={() => handleEdit(project)}
disabled={loading}
title="Bearbeiten"
>
</button>
<button
type="button"
className={`${styles.btnIcon} ${styles.btnIconDanger}`}
onClick={() => handleDelete(project.id)}
disabled={loading}
title="Löschen"
>
🗑
</button>
</div>
</div>
{project.tasks && (
<div className={styles.entryTasks}>
<RichText text={project.tasks} />
</div>
)}
</div>
))}
</div>