Files
HRM-System/resources/js/components/chatgpt/ChatGptModal.tsx
2026-04-13 08:16:56 +08:00

313 lines
10 KiB
TypeScript
Executable File

import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { Loader2, Sparkles, Copy, Check, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toast } from '@/components/custom-toast';
import { useStackedModal } from '@/hooks/useStackedModal';
import ReactCountryFlag from 'react-country-flag';
import languageData from '@/../../resources/lang/language.json';
interface ChatGptModalProps {
isOpen: boolean;
onClose: () => void;
onGenerate: (content: string) => void;
title?: string;
placeholder?: string;
}
export function ChatGptModal({
isOpen,
onClose,
onGenerate,
title = "AI Content Generator",
placeholder = "Describe what you want to generate..."
}: ChatGptModalProps) {
const { t } = useTranslation();
const { modalId, zIndex } = useStackedModal('chatgpt-modal', isOpen);
const [prompt, setPrompt] = useState('');
const [generatedContent, setGeneratedContent] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [language, setLanguage] = useState('en');
const [creativity, setCreativity] = useState('medium');
const [numResults, setNumResults] = useState(1);
const [maxLength, setMaxLength] = useState(150);
const [selectedText, setSelectedText] = useState('');
const [copied, setCopied] = useState(false);
useEffect(() => {
if (isOpen) {
// Force remove inert from document to allow ChatGPT modal interaction
const removeInert = () => {
document.body.removeAttribute('inert');
document.documentElement.removeAttribute('inert');
const allElements = document.querySelectorAll('[inert]');
allElements.forEach(el => el.removeAttribute('inert'));
};
removeInert();
// Keep removing inert as Radix might re-add it
const interval = setInterval(removeInert, 100);
return () => clearInterval(interval);
}
}, [isOpen]);
const handleGenerate = async () => {
if (!prompt.trim()) {
toast.error(t('Please enter a prompt'));
return;
}
setIsLoading(true);
try {
const response = await fetch(route('chatgpt.generate'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
},
body: JSON.stringify({
prompt,
language,
creativity,
num_results: numResults,
max_length: maxLength
})
});
const data = await response.json();
// Check both HTTP status and success flag from controller
if (response.ok && data.success !== false) {
setGeneratedContent(data.content);
toast.success(t('Content generated successfully'));
} else {
// Show error message from controller or default message
const errorMessage = data.message || t('Failed to generate content');
toast.error(errorMessage);
}
} catch (error) {
// Handle network errors or JSON parsing errors
const errorMessage = error instanceof Error ? error.message : t('Error connecting to AI service');
toast.error(errorMessage);
} finally {
setIsLoading(false);
}
};
const handleUse = () => {
if (generatedContent) {
onGenerate(generatedContent);
handleClose();
}
};
const handleClose = () => {
setPrompt('');
setGeneratedContent('');
setSelectedText('');
setCopied(false);
onClose();
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
toast.success(t('Copied to clipboard'));
setTimeout(() => setCopied(false), 2000);
} catch (error) {
toast.error(t('Failed to copy'));
}
};
const handleTextSelection = () => {
const textarea = document.getElementById('generated-content') as HTMLTextAreaElement;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selected = textarea.value.substring(start, end);
setSelectedText(selected);
}
};
if (!isOpen) {
return null;
}
const modalContent = (
<div
className="fixed inset-0 flex items-center justify-center"
style={{ zIndex: 99999 }}
data-chatgpt-modal
onClick={(e) => {
if (e.target === e.currentTarget) {
handleClose();
}
}}
>
<div className="fixed inset-0 bg-black/30" />
<div
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 border relative"
style={{ zIndex: 100000 }}
onClick={(e) => e.stopPropagation()}
>
<div className="p-6 border-b flex items-center justify-between">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Sparkles className="h-5 w-5 text-blue-500" />
{t(title)}
</h2>
<button
onClick={handleClose}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label>{t('Language')}</Label>
<Select value={language} onValueChange={setLanguage}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent style={{ zIndex: 100010 }}>
{languageData.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
<ReactCountryFlag
countryCode={lang.countryCode}
svg
style={{ width: '1em', height: '1em', marginRight: '8px' }}
/>
{lang.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>{t('AI Creativity')}</Label>
<Select value={creativity} onValueChange={setCreativity}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent style={{ zIndex: 100010 }}>
<SelectItem value="low">{t("Low")} (0.3)</SelectItem>
<SelectItem value="medium">{t("Medium")} (0.7)</SelectItem>
<SelectItem value="high">{t("High")} (0.9)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>{t('Number of Results')}</Label>
<Input
type="number"
value={numResults}
onChange={(e) => setNumResults(Number(e.target.value))}
min={1}
max={5}
/>
</div>
<div>
<Label>{t('Max Result Length')}</Label>
<Input
type="number"
value={maxLength}
onChange={(e) => setMaxLength(Number(e.target.value))}
min={50}
max={500}
/>
</div>
</div>
<div>
<Label htmlFor="prompt">{t('Add Text')}</Label>
<Textarea
id="prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={t(placeholder)}
rows={3}
className="mt-1"
/>
</div>
<Button
onClick={handleGenerate}
disabled={isLoading || !prompt.trim()}
className="w-full"
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t('Generating...')}
</>
) : (
<>
<Sparkles className="h-4 w-4 mr-2" />
{t('Generate')}
</>
)}
</Button>
{generatedContent && (
<div>
<div className="flex justify-between items-center mb-2">
<Label htmlFor="generated">{t('Output Text')}</Label>
<div className="flex gap-2">
{selectedText && (
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(selectedText)}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
{t('Copy Selected')}
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(generatedContent)}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
{t('Copy Text')}
</Button>
</div>
</div>
<Textarea
id="generated-content"
value={generatedContent}
onChange={(e) => setGeneratedContent(e.target.value)}
onSelect={handleTextSelection}
rows={6}
className="mt-1"
/>
<div className="flex gap-2 mt-2">
<Button onClick={handleUse} className="flex-1">
{t('Use This Content')}
</Button>
<Button variant="outline" onClick={handleGenerate} disabled={isLoading}>
{t('Regenerate')}
</Button>
</div>
</div>
)}
</div>
</div>
</div>
);
return typeof window !== 'undefined' ? createPortal(modalContent, document.body) : null;
}