| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332 |
- import * as React from 'react';
- import { AIGCSettings, AIGCArticle } from '../types';
- import { Icon } from './ui/Icon';
- import { generateArticle } from '../services/geminiService';
- import { format, parseISO } from 'date-fns';
- import { useTranslation } from '../hooks/useI18n';
- // --- Helper Components ---
- const ArticlePreview: React.FC<{ article: AIGCArticle; onEdit: () => void }> = ({ article, onEdit }) => {
- const { t } = useTranslation();
- return (
- <div className="h-full flex flex-col bg-white dark:bg-gray-800">
- <header className="p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0 flex justify-between items-center">
- <div className="min-w-0">
- <h3 className="text-xl font-semibold text-gray-900 dark:text-white truncate">{article.title}</h3>
- </div>
- <button onClick={onEdit} className="flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 text-sm font-semibold rounded-md hover:bg-gray-300 dark:hover:bg-gray-600">
- <Icon className="h-4 w-4"><path d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.536L16.732 3.732z" /></Icon>
- {t('edit')}
- </button>
- </header>
- <div className="flex-1 overflow-y-auto">
- <article className="prose prose-lg dark:prose-invert max-w-3xl mx-auto py-8 px-6">
- <h1>{article.title}</h1>
- <p className="lead">{article.summary}</p>
- <p className="text-sm text-gray-500 dark:text-gray-400">Published: {format(parseISO(article.publicationDate), 'MMMM d, yyyy')}</p>
- <hr className="dark:border-gray-600"/>
- <div dangerouslySetInnerHTML={{ __html: article.content }} />
- </article>
- </div>
- </div>
- );
- };
- const EditorToolbar: React.FC<{ onExecCommand: (command: string, value?: string) => void }> = ({ onExecCommand }) => {
- const inputClasses = "bg-gray-200 dark:bg-gray-600 border-gray-300 dark:border-gray-500 rounded p-1 text-xs focus:outline-none focus:ring-1 focus:ring-brand-primary text-gray-900 dark:text-white";
- const buttonClasses = "p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-600";
-
- return (
- <div className="flex flex-wrap items-center gap-2 p-2 bg-gray-100 dark:bg-gray-900/50 rounded-t-lg border-b border-gray-300 dark:border-gray-600 sticky top-0 z-10">
- {/* Style */}
- <button title="Bold" onMouseDown={e => e.preventDefault()} onClick={() => onExecCommand('bold')} className={buttonClasses}><strong>B</strong></button>
- <button title="Italic" onMouseDown={e => e.preventDefault()} onClick={() => onExecCommand('italic')} className={buttonClasses}><em>I</em></button>
- <div className="h-6 border-l border-gray-300 dark:border-gray-600 mx-1" />
- {/* Align */}
- <button title="Align Left" onMouseDown={e => e.preventDefault()} onClick={() => onExecCommand('justifyLeft')} className={buttonClasses}><Icon className="h-4 w-4"><path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25H12" /></Icon></button>
- <button title="Align Center" onMouseDown={e => e.preventDefault()} onClick={() => onExecCommand('justifyCenter')} className={buttonClasses}><Icon className="h-4 w-4"><path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /></Icon></button>
- <button title="Align Right" onMouseDown={e => e.preventDefault()} onClick={() => onExecCommand('justifyRight')} className={buttonClasses}><Icon className="h-4 w-4"><path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5M12 17.25h8.25" /></Icon></button>
- <div className="h-6 border-l border-gray-300 dark:border-gray-600 mx-1" />
- {/* Font */}
- <select onChange={(e) => onExecCommand('fontSize', e.target.value)} defaultValue="3" className={inputClasses}>
- <option value="1">Small</option>
- <option value="3">Normal</option>
- <option value="5">Large</option>
- <option value="7">Huge</option>
- </select>
- <input type="color" onChange={(e) => onExecCommand('foreColor', e.target.value)} defaultValue="#E5E7EB" className={`${inputClasses} h-8 w-8 p-0 align-middle`} />
- <div className="h-6 border-l border-gray-300 dark:border-gray-600 mx-1" />
- {/* Insert */}
- <button title="Insert Link" onMouseDown={e => e.preventDefault()} onClick={() => { const url = prompt('Enter URL:'); if (url) onExecCommand('createLink', url); }} className={buttonClasses}><Icon className="h-4 w-4"><path d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></Icon></button>
- <button title="Insert Image" onMouseDown={e => e.preventDefault()} onClick={() => { const url = prompt('Enter Image URL:'); if (url) onExecCommand('insertImage', url); }} className={buttonClasses}><Icon className="h-4 w-4"><path d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></Icon></button>
- </div>
- );
- };
- const ArticleEditor: React.FC<{
- initialData: Partial<AIGCArticle>;
- onSave: (data: Omit<AIGCArticle, 'id' | 'publicationDate'>) => void;
- onCancel: () => void;
- isNew: boolean;
- }> = ({ initialData, onSave, onCancel, isNew }) => {
- const { t } = useTranslation();
- const [data, setData] = React.useState(initialData);
- const contentEditableRef = React.useRef<HTMLDivElement>(null);
- const inputClasses = "w-full bg-gray-100 dark:bg-gray-700 p-2 rounded-md border border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-1 focus:ring-brand-primary";
- const labelClasses = "block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1";
- const [generationMode, setGenerationMode] = React.useState<'ai' | 'url'>('ai');
- const [generationInput, setGenerationInput] = React.useState('');
- const [isGenerating, setIsGenerating] = React.useState(false);
- const [showGenerator, setShowGenerator] = React.useState(false);
-
- const handleSave = () => {
- if (!data.title?.trim()) {
- alert(t('aigc.news.title_req'));
- return;
- }
- onSave({
- title: data.title,
- summary: data.summary || '',
- content: data.content || '',
- sourceType: data.sourceType || 'text',
- sourceUrl: data.sourceUrl
- });
- };
- const handleExecCommand = (command: string, value?: string) => {
- document.execCommand(command, false, value);
- contentEditableRef.current?.focus();
- };
-
- const handleContentChange = (e: React.FormEvent<HTMLDivElement>) => {
- setData(d => ({ ...d, content: e.currentTarget.innerHTML }));
- };
- const handleGenerate = async () => {
- setIsGenerating(true);
- try {
- const prompt = generationMode === 'url' ? `Parse and summarize this article: ${generationInput}` : generationInput;
- if (!generationInput.trim()) {
- alert(`Please enter a ${generationMode === 'url' ? 'URL' : 'topic'}.`);
- return;
- }
- const result = await generateArticle(prompt);
- setData(d => ({
- ...d,
- ...result,
- sourceType: generationMode === 'url' ? 'url' : 'generated',
- sourceUrl: generationMode === 'url' ? generationInput : undefined,
- }));
- // Keep the generator open for potential re-generation
- } catch (error) {
- console.error("Failed to generate article:", error);
- alert("Sorry, we couldn't generate the article at this time.");
- } finally {
- setIsGenerating(false);
- }
- };
-
- return (
- <div className="h-full flex flex-col">
- <header className="p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0 flex justify-between items-center">
- <h3 className="text-xl font-semibold text-gray-900 dark:text-white">{isNew ? t('aigc.news.create') : t('aigc.news.edit')}</h3>
- <div className="flex items-center gap-4">
- <button onClick={onCancel} className="text-sm font-semibold text-gray-600 dark:text-gray-300 hover:underline">{t('cancel')}</button>
- <button onClick={handleSave} className="px-5 py-2 bg-brand-primary text-white font-bold rounded-md hover:bg-brand-secondary">
- {isNew ? t('aigc.news.save_lib') : t('aigc.news.save_changes')}
- </button>
- </div>
- </header>
- <div className="flex-1 overflow-y-auto p-6 space-y-4">
- <div className="mb-4">
- <button
- onClick={() => setShowGenerator(prev => !prev)}
- className="w-full flex justify-between items-center p-3 bg-gray-100 dark:bg-gray-900/50 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
- >
- <div className="flex items-center gap-2">
- <Icon className="h-5 w-5 text-brand-primary">
- <path strokeLinecap="round" strokeLinejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c.251.023.501.05.75.082a9.75 9.75 0 016 6.062c.313.958.5 1.965.5 3.004v.75a2.25 2.25 0 01-2.25 2.25H3.75a2.25 2.25 0 01-2.25-2.25v-.75c0-1.04.187-2.046.5-3.004a9.75 9.75 0 016-6.062 12.312 12.312 0 01.75-.082zM9.75 18.75c-2.482 0-4.72-1.22-6.16-3.223" />
- </Icon>
- <span className="font-semibold text-gray-800 dark:text-gray-200">{t('aigc.news.ai_tools')}</span>
- </div>
- <Icon className={`h-5 w-5 text-gray-500 dark:text-gray-400 transition-transform ${showGenerator ? 'rotate-180' : ''}`}>
- <path d="M19 9l-7 7-7-7" />
- </Icon>
- </button>
- {showGenerator && (
- <div className="p-4 mt-2 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700 space-y-3">
- <div className="flex items-center gap-2 bg-gray-200 dark:bg-gray-800 p-1 rounded-lg">
- {(['ai', 'url'] as const).map(m => (
- <button key={m} onClick={() => setGenerationMode(m)} className={`flex-1 px-3 py-1.5 text-sm rounded-md capitalize transition-colors ${generationMode === m ? 'bg-brand-primary text-white shadow' : 'hover:bg-gray-300 dark:hover:bg-gray-700'}`}>
- {m === 'ai' ? t('aigc.news.generate_ai') : t('aigc.news.import_url')}
- </button>
- ))}
- </div>
- <div className="flex items-center gap-2">
- <input
- type="text"
- value={generationInput}
- onChange={e => setGenerationInput(e.target.value)}
- onKeyPress={e => e.key === 'Enter' && handleGenerate()}
- placeholder={generationMode === 'ai' ? t('aigc.news.enter_topic') : t('aigc.news.enter_url')}
- className={inputClasses}
- />
- <button onClick={handleGenerate} disabled={isGenerating} className="px-4 py-2 bg-gray-200 dark:bg-gray-600 text-sm font-semibold rounded-md hover:bg-gray-300 dark:hover:bg-gray-500 disabled:opacity-50">
- {isGenerating ? t('aigc.news.generating') : t('aigc.news.generate')}
- </button>
- </div>
- </div>
- )}
- </div>
- <div>
- <label htmlFor="title-write" className={labelClasses}>{t('link_editor.title')}</label>
- <input id="title-write" type="text" value={data.title || ''}
- onChange={e => setData(d => ({...d, title: e.target.value}))}
- className={inputClasses} />
- </div>
- <div>
- <label htmlFor="summary-write" className={labelClasses}>{t('aigc.news.summary')}</label>
- <textarea id="summary-write" rows={3} value={data.summary || ''}
- onChange={e => setData(d => ({...d, summary: e.target.value}))}
- className={inputClasses}></textarea>
- </div>
- <div>
- <label className={labelClasses}>{t('aigc.news.full_content')}</label>
- <div className="mt-1 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800">
- <EditorToolbar onExecCommand={handleExecCommand} />
- <div
- ref={contentEditableRef}
- contentEditable
- onInput={handleContentChange}
- dangerouslySetInnerHTML={{ __html: data.content || '' }}
- className="w-full min-h-[400px] bg-white dark:bg-gray-800 p-4 rounded-b-lg prose dark:prose-invert max-w-none focus:outline-none"
- />
- </div>
- </div>
- </div>
- </div>
- );
- };
- // --- Main Component ---
- interface NewsCreatorProps {
- aigcSettings: AIGCSettings;
- onUpdateAIGCSettings: (newSettings: AIGCSettings) => void;
- }
- const NewsCreator: React.FC<NewsCreatorProps> = ({ aigcSettings, onUpdateAIGCSettings }) => {
- const { t } = useTranslation();
- const [selectedArticleId, setSelectedArticleId] = React.useState<string | null>(null);
- const [isEditing, setIsEditing] = React.useState(false);
-
- const articles = aigcSettings.articles || [];
- const selectedArticle = React.useMemo(() => articles.find(a => a.id === selectedArticleId), [articles, selectedArticleId]);
- const handleSaveArticle = (data: Omit<AIGCArticle, 'id' | 'publicationDate'>) => {
- let updatedArticles;
- if (selectedArticleId && !selectedArticleId.startsWith('new-')) { // Editing existing article
- updatedArticles = articles.map(a => a.id === selectedArticleId ? { ...a, ...data, publicationDate: new Date().toISOString() } : a);
- } else { // Creating new article
- const newArticle: AIGCArticle = { id: `article-${Date.now()}`, publicationDate: new Date().toISOString(), ...data };
- if (selectedArticleId?.startsWith('new-')) {
- updatedArticles = articles.map(a => a.id === selectedArticleId ? newArticle : a);
- } else {
- updatedArticles = [newArticle, ...articles];
- }
- setSelectedArticleId(newArticle.id);
- }
- onUpdateAIGCSettings({ ...aigcSettings, articles: updatedArticles });
- setIsEditing(false);
- };
-
- const handleDeleteArticle = (id: string) => {
- if (!window.confirm(t('aigc.news.delete_confirm'))) return;
- const updatedArticles = articles.filter(a => a.id !== id);
- onUpdateAIGCSettings({ ...aigcSettings, articles: updatedArticles });
- if (selectedArticleId === id) {
- setSelectedArticleId(null);
- setIsEditing(false);
- }
- };
-
- const handleCreateNewArticle = () => {
- if (selectedArticleId?.startsWith('new-')) return; // Don't create another if one is in progress
- const tempId = `new-${Date.now()}`;
- const tempArticle: AIGCArticle = {
- id: tempId,
- publicationDate: new Date().toISOString(),
- title: '',
- summary: '',
- content: '',
- sourceType: 'text',
- };
- onUpdateAIGCSettings({ ...aigcSettings, articles: [tempArticle, ...articles] });
- setSelectedArticleId(tempId);
- setIsEditing(true);
- };
-
- const handleCancelEdit = () => {
- if (selectedArticleId && selectedArticleId.startsWith('new-')) {
- const updatedArticles = articles.filter(a => a.id !== selectedArticleId);
- onUpdateAIGCSettings({ ...aigcSettings, articles: updatedArticles });
- const firstRealArticle = updatedArticles.find(a => !a.id.startsWith('new-'));
- setSelectedArticleId(firstRealArticle ? firstRealArticle.id : null);
- }
- setIsEditing(false);
- };
-
- return (
- <div className="flex h-full bg-white dark:bg-gray-800">
- <aside className="w-1/3 max-w-sm flex flex-col border-r border-gray-200 dark:border-gray-700">
- <header className="p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
- <h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">{t('aigc.news.title')}</h2>
- <button
- onClick={handleCreateNewArticle}
- className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-brand-primary text-white font-semibold rounded-lg hover:bg-brand-secondary transition-colors shadow-sm"
- >
- <Icon className="h-5 w-5"><path d="M12 4v16m8-8H4" /></Icon>
- {t('aigc.news.create')}
- </button>
- </header>
- <div className="flex-1 overflow-y-auto p-2 space-y-2">
- {articles.filter(a => !a.id.startsWith('new-')).sort((a,b) => parseISO(b.publicationDate).getTime() - parseISO(a.publicationDate).getTime()).map(article => (
- <button key={article.id} onClick={() => { setSelectedArticleId(article.id); setIsEditing(false); }} className={`w-full p-3 rounded-lg text-left group transition-colors ${selectedArticleId === article.id && !isEditing ? 'bg-brand-primary/10' : 'hover:bg-gray-100 dark:hover:bg-gray-800'}`}>
- <div className="flex justify-between items-start">
- <p className={`font-semibold text-gray-900 dark:text-white truncate ${selectedArticleId === article.id && !isEditing ? 'text-brand-primary' : ''}`}>{article.title}</p>
- <button onClick={(e) => { e.stopPropagation(); handleDeleteArticle(article.id); }} className="text-gray-400 hover:text-red-500 opacity-0 group-hover:opacity-100 flex-shrink-0 ml-2"><Icon className="h-4 w-4"><path d="M6 18L18 6M6 6l12 12" /></Icon></button>
- </div>
- <p className="text-xs text-gray-500 dark:text-gray-400 truncate mt-1">{article.summary}</p>
- </button>
- ))}
- </div>
- </aside>
- <main className="flex-1">
- {selectedArticle ? (
- isEditing ? (
- <ArticleEditor
- initialData={selectedArticle}
- onSave={handleSaveArticle}
- onCancel={handleCancelEdit}
- isNew={selectedArticle.id.startsWith('new-')}
- />
- ) : (
- <ArticlePreview article={selectedArticle} onEdit={() => setIsEditing(true)} />
- )
- ) : (
- <div className="flex flex-col items-center justify-center h-full text-center text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50">
- <Icon className="h-16 w-16 mb-4 text-gray-300 dark:text-gray-600"><path strokeLinecap="round" strokeLinejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3h3m-3 4h3m-3 4h3" /></Icon>
- <h3 className="text-xl font-semibold">{t('aigc.news.empty_title')}</h3>
- <p>{t('aigc.news.empty_desc')}</p>
- </div>
- )}
- </main>
- </div>
- );
- };
- export default NewsCreator;
|