Dateien nach "b2b-marketing-assistant/components" hochladen
This commit is contained in:
299
b2b-marketing-assistant/components/StepDisplay.tsx
Normal file
299
b2b-marketing-assistant/components/StepDisplay.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { CopyIcon, ClipboardTableIcon, SearchIcon, TrashIcon, LoadingSpinner, CheckIcon, XMarkIcon } from './Icons';
|
||||
import { convertArrayToTsv } from '../services/export';
|
||||
import { translations } from '../constants';
|
||||
|
||||
interface StepDisplayProps {
|
||||
title: string;
|
||||
summary?: string[];
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
onDataChange: (newRows: string[][]) => void;
|
||||
canAddRows?: boolean;
|
||||
canDeleteRows?: boolean;
|
||||
onEnrichRow?: (productName: string, productUrl?: string) => Promise<void>;
|
||||
isEnriching?: boolean;
|
||||
t: typeof translations.de;
|
||||
}
|
||||
|
||||
export const StepDisplay: React.FC<StepDisplayProps> = ({ title, summary, headers, rows, onDataChange, canAddRows = false, canDeleteRows = false, onEnrichRow, isEnriching = false, t }) => {
|
||||
const [copySuccess, setCopySuccess] = useState('');
|
||||
const [filterQuery, setFilterQuery] = useState('');
|
||||
const [isAddingRow, setIsAddingRow] = useState(false);
|
||||
const [newRowValue, setNewRowValue] = useState('');
|
||||
const [newRowUrl, setNewRowUrl] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
if (!filterQuery) {
|
||||
return rows;
|
||||
}
|
||||
return rows.filter(row =>
|
||||
row.some(cell =>
|
||||
cell.toLowerCase().includes(filterQuery.toLowerCase())
|
||||
)
|
||||
);
|
||||
}, [rows, filterQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAddingRow && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isAddingRow]);
|
||||
|
||||
const handleCellChange = (originalRowIndex: number, colIndex: number, value: string) => {
|
||||
const newRows = rows.map((row, rIdx) =>
|
||||
rIdx === originalRowIndex
|
||||
? row.map((cell, cIdx) => cIdx === colIndex ? value : cell)
|
||||
: row
|
||||
);
|
||||
onDataChange(newRows);
|
||||
};
|
||||
|
||||
const handleCopyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text).catch(err => console.error('Failed to copy text: ', err));
|
||||
};
|
||||
|
||||
const handleCopyTable = () => {
|
||||
const tsvString = convertArrayToTsv(headers, filteredRows);
|
||||
navigator.clipboard.writeText(tsvString).then(() => {
|
||||
setCopySuccess(t.copySuccess);
|
||||
setTimeout(() => setCopySuccess(''), 2000);
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy table: ', err);
|
||||
setCopySuccess(t.copyFailure);
|
||||
setTimeout(() => setCopySuccess(''), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddRowClick = () => {
|
||||
if (onEnrichRow) {
|
||||
setIsAddingRow(true);
|
||||
} else {
|
||||
const newEmptyRow = Array(headers.length).fill('');
|
||||
onDataChange([...rows, newEmptyRow]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmAddRow = () => {
|
||||
if (newRowValue.trim() && onEnrichRow) {
|
||||
onEnrichRow(newRowValue.trim(), newRowUrl.trim());
|
||||
setNewRowValue('');
|
||||
setNewRowUrl('');
|
||||
setIsAddingRow(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelAddRow = () => {
|
||||
setNewRowValue('');
|
||||
setNewRowUrl('');
|
||||
setIsAddingRow(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleConfirmAddRow();
|
||||
} else if (e.key === 'Escape') {
|
||||
handleCancelAddRow();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRow = (rowIndexToDelete: number) => {
|
||||
const newRows = rows.filter((_, index) => index !== rowIndexToDelete);
|
||||
onDataChange(newRows);
|
||||
};
|
||||
|
||||
const getColumnStyle = (header: string): React.CSSProperties => {
|
||||
const lowerHeader = header.toLowerCase();
|
||||
|
||||
const wideColumnHeaders = [
|
||||
'beschreibung',
|
||||
'description',
|
||||
'painpoint',
|
||||
'gain',
|
||||
'nutzen',
|
||||
'benefit',
|
||||
'kernbotschaft',
|
||||
'core message',
|
||||
'li-dm',
|
||||
'verantwortungsbereich',
|
||||
'area of responsibility'
|
||||
];
|
||||
|
||||
if (wideColumnHeaders.some(keyword => lowerHeader.includes(keyword))) {
|
||||
return { width: '25%' };
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
const loadingText = t.loadingButton.replace('...', '');
|
||||
const isLoadingCell = (cell: string) => cell.toLowerCase().includes(loadingText.toLowerCase());
|
||||
|
||||
return (
|
||||
<section className="bg-white dark:bg-slate-800/50 rounded-2xl shadow-lg p-6 md:p-8 border border-slate-200 dark:border-slate-700 print:shadow-none print:border-none print:[break-inside:avoid]">
|
||||
<div className="flex flex-col sm:flex-row justify-between sm:items-start mb-6 gap-4">
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-900 dark:text-white">{title}</h2>
|
||||
<div className="relative ml-auto">
|
||||
<button
|
||||
onClick={handleCopyTable}
|
||||
className="print:hidden flex items-center px-3 py-1.5 border border-slate-300 dark:border-slate-600 text-xs font-medium rounded-md text-slate-600 dark:text-slate-300 bg-slate-50 dark:bg-slate-700 hover:bg-slate-100 dark:hover:bg-slate-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500 whitespace-nowrap"
|
||||
aria-label={t.copyTableButton}
|
||||
>
|
||||
<ClipboardTableIcon className="h-4 w-4 mr-2" />
|
||||
{t.copyTableButton}
|
||||
</button>
|
||||
{copySuccess && <span className={`absolute -bottom-6 right-0 text-xs whitespace-nowrap ${copySuccess === t.copyFailure ? 'text-red-500' : 'text-green-600 dark:text-green-400'}`}>{copySuccess}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{summary && summary.length > 0 && (
|
||||
<div className="mb-6 bg-slate-100 dark:bg-slate-800 p-4 rounded-lg">
|
||||
<h3 className="font-semibold text-slate-800 dark:text-slate-200 mb-2">{t.summaryTitle}</h3>
|
||||
<ul className="list-disc list-inside space-y-1 text-slate-700 dark:text-slate-300">
|
||||
{summary.map((item, index) => <li key={index}>{item}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4 print:hidden">
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<SearchIcon className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={filterQuery}
|
||||
onChange={(e) => setFilterQuery(e.target.value)}
|
||||
placeholder={t.filterPlaceholder}
|
||||
className="block w-full rounded-md border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 py-2 pl-10 pr-3 shadow-sm focus:border-sky-500 focus:ring-sky-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full table-fixed text-sm text-left text-slate-500 dark:text-slate-400">
|
||||
<thead className="text-xs text-slate-700 dark:text-slate-300 uppercase bg-slate-100 dark:bg-slate-700">
|
||||
<tr>
|
||||
{headers.map((header, index) => (
|
||||
<th key={index} scope="col" className="px-6 py-3" style={getColumnStyle(header)}>{header}</th>
|
||||
))}
|
||||
{canDeleteRows && <th scope="col" className="px-6 py-3 w-20 text-center">Aktionen</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRows.map((row, rowIndex) => {
|
||||
const originalRowIndex = rows.findIndex(originalRow => originalRow === row);
|
||||
return (
|
||||
<tr key={originalRowIndex} className="bg-white dark:bg-slate-800 border-b dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-700/50">
|
||||
{row.map((cell, colIndex) => (
|
||||
<td key={colIndex} className="px-6 py-8 align-top">
|
||||
{isLoadingCell(cell) ? (
|
||||
<div className="flex items-center justify-center min-h-[160px] p-2 text-slate-500 dark:text-slate-400">
|
||||
<LoadingSpinner className="text-sky-500" />
|
||||
<span className="ml-2 animate-pulse">{t.loadingButton}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative group">
|
||||
<textarea
|
||||
value={cell}
|
||||
onChange={(e) => handleCellChange(originalRowIndex, colIndex, e.target.value)}
|
||||
className="w-full min-h-[160px] p-2 bg-transparent border border-transparent hover:border-slate-300 dark:hover:border-slate-600 focus:border-sky-500 focus:ring-sky-500 focus:outline-none rounded-md resize-y transition-all"
|
||||
rows={7}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleCopyToClipboard(cell)}
|
||||
className="absolute top-1 right-1 p-1 text-slate-400 hover:text-sky-500 bg-white/50 dark:bg-slate-800/50 rounded-md opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity print:hidden"
|
||||
aria-label={t.copyToClipboardAria}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4"/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
{canDeleteRows && (
|
||||
<td className="px-6 py-8 align-middle text-center">
|
||||
<button
|
||||
onClick={() => handleDeleteRow(originalRowIndex)}
|
||||
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-100 dark:hover:bg-slate-700 rounded-full transition-colors print:hidden"
|
||||
aria-label={t.deleteRowAria}
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{filteredRows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={headers.length + (canDeleteRows ? 1 : 0)} className="text-center py-8 text-slate-500 dark:text-slate-400">
|
||||
{t.noFilterResults.replace('{{query}}', filterQuery)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{canAddRows && (
|
||||
<div className="mt-6 text-left print:hidden">
|
||||
{isAddingRow ? (
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 w-full max-w-2xl">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={newRowValue}
|
||||
onChange={(e) => setNewRowValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t.newProductPrompt}
|
||||
className="flex-1 block w-full rounded-md border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 py-2 px-3 shadow-sm focus:border-sky-500 focus:ring-sky-500 sm:text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newRowUrl}
|
||||
onChange={(e) => setNewRowUrl(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t.productUrlPlaceholder}
|
||||
className="flex-1 block w-full rounded-md border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 py-2 px-3 shadow-sm focus:border-sky-500 focus:ring-sky-500 sm:text-sm"
|
||||
/>
|
||||
<div className="flex items-center space-x-2 self-end sm:self-auto">
|
||||
<button
|
||||
onClick={handleConfirmAddRow}
|
||||
className="p-2 bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50 rounded-md transition-colors"
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelAddRow}
|
||||
className="p-2 bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50 rounded-md transition-colors"
|
||||
>
|
||||
<XMarkIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleAddRowClick}
|
||||
disabled={isEnriching}
|
||||
className="flex items-center px-4 py-2 border border-slate-300 dark:border-slate-600 text-sm font-medium rounded-md text-slate-600 dark:text-slate-300 bg-slate-50 dark:bg-slate-700 hover:bg-slate-100 dark:hover:bg-slate-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500 disabled:bg-slate-300 dark:disabled:bg-slate-600 disabled:cursor-wait"
|
||||
>
|
||||
{isEnriching ? (
|
||||
<>
|
||||
<LoadingSpinner className="mr-2 text-slate-500" />
|
||||
{t.loadingButton}
|
||||
</>
|
||||
) : (
|
||||
t.addRowButton
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</section>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user