Dateien nach "b2b-marketing-assistant/components" hochladen
This commit is contained in:
85
b2b-marketing-assistant/components/Icons.tsx
Normal file
85
b2b-marketing-assistant/components/Icons.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const LoadingSpinner: React.FC<{ className?: string }> = ({ className = '' }) => (
|
||||||
|
<svg className={`animate-spin h-5 w-5 text-white ${className}`} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SparklesIcon: React.FC<{ className?: string }> = ({ className = '' }) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className={`h-5 w-5 ${className}`}>
|
||||||
|
<path fillRule="evenodd" d="M10.868 2.884c.321.772.117 1.671-.43 2.22l-.49.49a.45.45 0 01-.636 0l-.49-.49a1.5 1.5 0 01-.43-2.22c.321-.772 1.135-1.226 1.986-1.226s1.665.454 1.986 1.226zM5.334 5.334a.45.45 0 010 .636l-.49.49a1.5 1.5 0 01-2.22.43c-.772-.321-1.226-1.135-1.226-1.986s.454-1.665 1.226-1.986a1.5 1.5 0 012.22.43l.49.49a.45.45 0 01.636 0zm11.332 0a.45.45 0 01.636 0l.49.49a1.5 1.5 0 01.43 2.22c-.321.772-1.135 1.226-1.986 1.226s-1.665-.454-1.986-1.226a1.5 1.5 0 01.43-2.22l.49-.49a.45.45 0 010-.636zM14.666 14.666a.45.45 0 010-.636l.49-.49a1.5 1.5 0 012.22-.43c.772.321 1.226 1.135 1.226 1.986s-.454 1.665-1.226 1.986a1.5 1.5 0 01-2.22-.43l-.49-.49a.45.45 0 01-.636 0zM2.884 10.868a1.5 1.5 0 012.22-.43l.49.49a.45.45 0 010 .636l-.49.49a1.5 1.5 0 01-2.22.43c-.772-.321-1.226-1.135-1.226-1.986s.454-1.665 1.226-1.986z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CopyIcon: React.FC<{ className?: string }> = ({ className = '' }) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={`h-6 w-6 ${className}`}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const BotIcon: React.FC<{ className?: string }> = ({ className = '' }) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={`h-6 w-6 ${className}`}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 7.5l.415-.207a.75.75 0 011.085.67V10.5m0 0h6m-6 0a.75.75 0 001.085.67l.415-.207M8.25 10.5V7.5m0 3H7.5m1.5 0a.75.75 0 01.67 1.085l-.207.415a.75.75 0 01-1.085.67L7.5 12.75m0-3h-1.5m1.5 0a.75.75 0 00-.67 1.085l.207.415a.75.75 0 001.085.67L8.25 12.75m0-3h1.5m-1.5 0a.75.75 0 01-.67-1.085l.207-.415a.75.75 0 011.085-.67L8.25 9m0 3h-1.5M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 13.5a11.25 11.25 0 0119.5 0v6.75c0 2.347-1.903 4.25-4.25 4.25H6.5c-2.347 0-4.25-1.903-4.25-4.25V13.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DownloadIcon: React.FC<{ className?: string }> = ({ className = '' }) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={`h-6 w-6 ${className}`}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PrintIcon: React.FC<{ className?: string }> = ({ className = '' }) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={`h-6 w-6 ${className}`}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6.72 13.829c-.24.03-.48.062-.72.096m.72-.096a42.415 42.415 0 0110.56 0m-10.56 0L6 18.25m0 0h12M6 18.25a42.415 42.415 0 0010.56 0m-10.56 0L6 13.829m0 0L6 18.25m6-13.5L12 3m0 0L12 3m0 0v18m0-18L12 3m0 0h6m-6 0H6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const MarkdownIcon: React.FC<{ className?: string }> = ({ className = '' }) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={`h-6 w-6 ${className}`}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 2.25a.75.75 0 01.75.75v18a.75.75 0 01-1.5 0v-18A.75.75 0 0112 2.25zM6 4.5A2.25 2.25 0 018.25 2.25h.5a2.25 2.25 0 012.25 2.25v.5A2.25 2.25 0 018.75 7.5h-.5A2.25 2.25 0 016 5.25v-.75zm12 0A2.25 2.25 0 0015.75 2.25h-.5a2.25 2.25 0 00-2.25 2.25v.5A2.25 2.25 0 0015.25 7.5h.5A2.25 2.25 0 0018 5.25v-.75zm-6 15a.75.75 0 01-.75-.75v-3a.75.75 0 011.5 0v3a.75.75 0 01-.75.75z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ChevronDownIcon: React.FC<{ className?: string }> = ({ className = '' }) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className={`h-5 w-5 ${className}`}>
|
||||||
|
<path fillRule="evenodd" d="M5.22 8.22a.75.75 0 011.06 0L10 11.94l3.72-3.72a.75.75 0 111.06 1.06l-4.25 4.25a.75.75 0 01-1.06 0L5.22 9.28a.75.75 0 010-1.06z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ClipboardTableIcon: React.FC<{ className?: string }> = ({ className = '' }) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={`h-6 w-6 ${className}`}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-4.5 0V6.75A.75.75 0 0113.5 6z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6V3.75A2.25 2.25 0 0115.75 1.5h1.5A2.25 2.25 0 0119.5 3.75V6" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 6h-6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SearchIcon: React.FC<{ className?: string }> = ({ className = '' }) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={`h-6 w-6 ${className}`}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TrashIcon: React.FC<{ className?: string }> = ({ className = '' }) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={`h-6 w-6 ${className}`}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CheckIcon: React.FC<{ className?: string }> = ({ className = '' }) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={`h-5 w-5 ${className}`}>
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const XMarkIcon: React.FC<{ className?: string }> = ({ className = '' }) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={`h-5 w-5 ${className}`}>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
135
b2b-marketing-assistant/components/InputForm.tsx
Normal file
135
b2b-marketing-assistant/components/InputForm.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { InputData } from '../types';
|
||||||
|
import { LANGUAGE_OPTIONS, CHANNEL_OPTIONS, translations } from '../constants';
|
||||||
|
import { LoadingSpinner, SparklesIcon } from './Icons';
|
||||||
|
|
||||||
|
interface InputFormProps {
|
||||||
|
inputData: InputData;
|
||||||
|
setInputData: React.Dispatch<React.SetStateAction<InputData>>;
|
||||||
|
onGenerate: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
t: typeof translations.de;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InputForm: React.FC<InputFormProps> = ({ inputData, setInputData, onGenerate, isLoading, t }) => {
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setInputData(prev => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChannelChange = (channel: string) => {
|
||||||
|
setInputData(prev => {
|
||||||
|
const newChannels = prev.channels.includes(channel)
|
||||||
|
? prev.channels.filter(c => c !== channel)
|
||||||
|
: [...prev.channels, channel];
|
||||||
|
return { ...prev, channels: newChannels };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); onGenerate(); }} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="companyUrl" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||||
|
{t.companyUrlLabel}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
name="companyUrl"
|
||||||
|
id="companyUrl"
|
||||||
|
value={inputData.companyUrl}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="block w-full px-4 py-2 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500"
|
||||||
|
placeholder={t.companyUrlPlaceholder}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="language" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||||
|
{t.targetLanguageLabel}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="language"
|
||||||
|
name="language"
|
||||||
|
value={inputData.language}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="block w-full pl-3 pr-10 py-2 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500"
|
||||||
|
>
|
||||||
|
{LANGUAGE_OPTIONS.map(option => (
|
||||||
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="focus" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||||
|
{t.productFocusLabel} <span className="text-slate-400">{t.productFocusOptional}</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="focus"
|
||||||
|
id="focus"
|
||||||
|
value={inputData.focus}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="block w-full px-4 py-2 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500"
|
||||||
|
placeholder={t.productFocusPlaceholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="regions" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||||
|
{t.regionsLabel} <span className="text-slate-400">{t.regionsOptional}</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="regions"
|
||||||
|
id="regions"
|
||||||
|
value={inputData.regions}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="block w-full px-4 py-2 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500"
|
||||||
|
placeholder={t.regionsPlaceholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
{t.channelsLabel} <span className="text-slate-400">{t.channelsOptional}</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{CHANNEL_OPTIONS.map(channel => (
|
||||||
|
<label key={channel} className="flex items-center space-x-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={inputData.channels.includes(channel)}
|
||||||
|
onChange={() => handleChannelChange(channel)}
|
||||||
|
className="h-4 w-4 rounded border-slate-300 dark:border-slate-600 text-sky-600 focus:ring-sky-500 bg-slate-100 dark:bg-slate-700"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-300">{channel}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-sky-600 hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500 disabled:bg-slate-400 dark:disabled:bg-slate-600 disabled:cursor-not-allowed transition-colors duration-200"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<LoadingSpinner className="mr-3" />
|
||||||
|
{t.analyzingButton}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SparklesIcon className="mr-2 h-5 w-5" />
|
||||||
|
{t.generateButton}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
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