mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
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:
parent
4f141b94e5
commit
b872b7e708
4 changed files with 285 additions and 28 deletions
22
Summarize.md
22
Summarize.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,7 +132,11 @@ export function ProjectsSection({ projects, onUpdate }: ProjectsSectionProps) {
|
|||
{projects.length > 0 ? (
|
||||
<div className={styles.entryList}>
|
||||
{projects.map((project) => (
|
||||
<div key={project.id} className={styles.entryItem}>
|
||||
<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>
|
||||
|
|
@ -100,6 +165,12 @@ export function ProjectsSection({ projects, onUpdate }: ProjectsSectionProps) {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{project.tasks && (
|
||||
<div className={styles.entryTasks}>
|
||||
<RichText text={project.tasks} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue