NewsCreator.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. import * as React from 'react';
  2. import { AIGCSettings, AIGCArticle } from '../types';
  3. import { Icon } from './ui/Icon';
  4. import { generateArticle } from '../services/geminiService';
  5. import { format, parseISO } from 'date-fns';
  6. import { useTranslation } from '../hooks/useI18n';
  7. // --- Helper Components ---
  8. const ArticlePreview: React.FC<{ article: AIGCArticle; onEdit: () => void }> = ({ article, onEdit }) => {
  9. const { t } = useTranslation();
  10. return (
  11. <div className="h-full flex flex-col bg-white dark:bg-gray-800">
  12. <header className="p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0 flex justify-between items-center">
  13. <div className="min-w-0">
  14. <h3 className="text-xl font-semibold text-gray-900 dark:text-white truncate">{article.title}</h3>
  15. </div>
  16. <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">
  17. <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>
  18. {t('edit')}
  19. </button>
  20. </header>
  21. <div className="flex-1 overflow-y-auto">
  22. <article className="prose prose-lg dark:prose-invert max-w-3xl mx-auto py-8 px-6">
  23. <h1>{article.title}</h1>
  24. <p className="lead">{article.summary}</p>
  25. <p className="text-sm text-gray-500 dark:text-gray-400">Published: {format(parseISO(article.publicationDate), 'MMMM d, yyyy')}</p>
  26. <hr className="dark:border-gray-600"/>
  27. <div dangerouslySetInnerHTML={{ __html: article.content }} />
  28. </article>
  29. </div>
  30. </div>
  31. );
  32. };
  33. const EditorToolbar: React.FC<{ onExecCommand: (command: string, value?: string) => void }> = ({ onExecCommand }) => {
  34. 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";
  35. const buttonClasses = "p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-600";
  36. return (
  37. <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">
  38. {/* Style */}
  39. <button title="Bold" onMouseDown={e => e.preventDefault()} onClick={() => onExecCommand('bold')} className={buttonClasses}><strong>B</strong></button>
  40. <button title="Italic" onMouseDown={e => e.preventDefault()} onClick={() => onExecCommand('italic')} className={buttonClasses}><em>I</em></button>
  41. <div className="h-6 border-l border-gray-300 dark:border-gray-600 mx-1" />
  42. {/* Align */}
  43. <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>
  44. <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>
  45. <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>
  46. <div className="h-6 border-l border-gray-300 dark:border-gray-600 mx-1" />
  47. {/* Font */}
  48. <select onChange={(e) => onExecCommand('fontSize', e.target.value)} defaultValue="3" className={inputClasses}>
  49. <option value="1">Small</option>
  50. <option value="3">Normal</option>
  51. <option value="5">Large</option>
  52. <option value="7">Huge</option>
  53. </select>
  54. <input type="color" onChange={(e) => onExecCommand('foreColor', e.target.value)} defaultValue="#E5E7EB" className={`${inputClasses} h-8 w-8 p-0 align-middle`} />
  55. <div className="h-6 border-l border-gray-300 dark:border-gray-600 mx-1" />
  56. {/* Insert */}
  57. <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>
  58. <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>
  59. </div>
  60. );
  61. };
  62. const ArticleEditor: React.FC<{
  63. initialData: Partial<AIGCArticle>;
  64. onSave: (data: Omit<AIGCArticle, 'id' | 'publicationDate'>) => void;
  65. onCancel: () => void;
  66. isNew: boolean;
  67. }> = ({ initialData, onSave, onCancel, isNew }) => {
  68. const { t } = useTranslation();
  69. const [data, setData] = React.useState(initialData);
  70. const contentEditableRef = React.useRef<HTMLDivElement>(null);
  71. 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";
  72. const labelClasses = "block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1";
  73. const [generationMode, setGenerationMode] = React.useState<'ai' | 'url'>('ai');
  74. const [generationInput, setGenerationInput] = React.useState('');
  75. const [isGenerating, setIsGenerating] = React.useState(false);
  76. const [showGenerator, setShowGenerator] = React.useState(false);
  77. const handleSave = () => {
  78. if (!data.title?.trim()) {
  79. alert(t('aigc.news.title_req'));
  80. return;
  81. }
  82. onSave({
  83. title: data.title,
  84. summary: data.summary || '',
  85. content: data.content || '',
  86. sourceType: data.sourceType || 'text',
  87. sourceUrl: data.sourceUrl
  88. });
  89. };
  90. const handleExecCommand = (command: string, value?: string) => {
  91. document.execCommand(command, false, value);
  92. contentEditableRef.current?.focus();
  93. };
  94. const handleContentChange = (e: React.FormEvent<HTMLDivElement>) => {
  95. setData(d => ({ ...d, content: e.currentTarget.innerHTML }));
  96. };
  97. const handleGenerate = async () => {
  98. setIsGenerating(true);
  99. try {
  100. const prompt = generationMode === 'url' ? `Parse and summarize this article: ${generationInput}` : generationInput;
  101. if (!generationInput.trim()) {
  102. alert(`Please enter a ${generationMode === 'url' ? 'URL' : 'topic'}.`);
  103. return;
  104. }
  105. const result = await generateArticle(prompt);
  106. setData(d => ({
  107. ...d,
  108. ...result,
  109. sourceType: generationMode === 'url' ? 'url' : 'generated',
  110. sourceUrl: generationMode === 'url' ? generationInput : undefined,
  111. }));
  112. // Keep the generator open for potential re-generation
  113. } catch (error) {
  114. console.error("Failed to generate article:", error);
  115. alert("Sorry, we couldn't generate the article at this time.");
  116. } finally {
  117. setIsGenerating(false);
  118. }
  119. };
  120. return (
  121. <div className="h-full flex flex-col">
  122. <header className="p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0 flex justify-between items-center">
  123. <h3 className="text-xl font-semibold text-gray-900 dark:text-white">{isNew ? t('aigc.news.create') : t('aigc.news.edit')}</h3>
  124. <div className="flex items-center gap-4">
  125. <button onClick={onCancel} className="text-sm font-semibold text-gray-600 dark:text-gray-300 hover:underline">{t('cancel')}</button>
  126. <button onClick={handleSave} className="px-5 py-2 bg-brand-primary text-white font-bold rounded-md hover:bg-brand-secondary">
  127. {isNew ? t('aigc.news.save_lib') : t('aigc.news.save_changes')}
  128. </button>
  129. </div>
  130. </header>
  131. <div className="flex-1 overflow-y-auto p-6 space-y-4">
  132. <div className="mb-4">
  133. <button
  134. onClick={() => setShowGenerator(prev => !prev)}
  135. 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"
  136. >
  137. <div className="flex items-center gap-2">
  138. <Icon className="h-5 w-5 text-brand-primary">
  139. <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" />
  140. </Icon>
  141. <span className="font-semibold text-gray-800 dark:text-gray-200">{t('aigc.news.ai_tools')}</span>
  142. </div>
  143. <Icon className={`h-5 w-5 text-gray-500 dark:text-gray-400 transition-transform ${showGenerator ? 'rotate-180' : ''}`}>
  144. <path d="M19 9l-7 7-7-7" />
  145. </Icon>
  146. </button>
  147. {showGenerator && (
  148. <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">
  149. <div className="flex items-center gap-2 bg-gray-200 dark:bg-gray-800 p-1 rounded-lg">
  150. {(['ai', 'url'] as const).map(m => (
  151. <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'}`}>
  152. {m === 'ai' ? t('aigc.news.generate_ai') : t('aigc.news.import_url')}
  153. </button>
  154. ))}
  155. </div>
  156. <div className="flex items-center gap-2">
  157. <input
  158. type="text"
  159. value={generationInput}
  160. onChange={e => setGenerationInput(e.target.value)}
  161. onKeyPress={e => e.key === 'Enter' && handleGenerate()}
  162. placeholder={generationMode === 'ai' ? t('aigc.news.enter_topic') : t('aigc.news.enter_url')}
  163. className={inputClasses}
  164. />
  165. <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">
  166. {isGenerating ? t('aigc.news.generating') : t('aigc.news.generate')}
  167. </button>
  168. </div>
  169. </div>
  170. )}
  171. </div>
  172. <div>
  173. <label htmlFor="title-write" className={labelClasses}>{t('link_editor.title')}</label>
  174. <input id="title-write" type="text" value={data.title || ''}
  175. onChange={e => setData(d => ({...d, title: e.target.value}))}
  176. className={inputClasses} />
  177. </div>
  178. <div>
  179. <label htmlFor="summary-write" className={labelClasses}>{t('aigc.news.summary')}</label>
  180. <textarea id="summary-write" rows={3} value={data.summary || ''}
  181. onChange={e => setData(d => ({...d, summary: e.target.value}))}
  182. className={inputClasses}></textarea>
  183. </div>
  184. <div>
  185. <label className={labelClasses}>{t('aigc.news.full_content')}</label>
  186. <div className="mt-1 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800">
  187. <EditorToolbar onExecCommand={handleExecCommand} />
  188. <div
  189. ref={contentEditableRef}
  190. contentEditable
  191. onInput={handleContentChange}
  192. dangerouslySetInnerHTML={{ __html: data.content || '' }}
  193. 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"
  194. />
  195. </div>
  196. </div>
  197. </div>
  198. </div>
  199. );
  200. };
  201. // --- Main Component ---
  202. interface NewsCreatorProps {
  203. aigcSettings: AIGCSettings;
  204. onUpdateAIGCSettings: (newSettings: AIGCSettings) => void;
  205. }
  206. const NewsCreator: React.FC<NewsCreatorProps> = ({ aigcSettings, onUpdateAIGCSettings }) => {
  207. const { t } = useTranslation();
  208. const [selectedArticleId, setSelectedArticleId] = React.useState<string | null>(null);
  209. const [isEditing, setIsEditing] = React.useState(false);
  210. const articles = aigcSettings.articles || [];
  211. const selectedArticle = React.useMemo(() => articles.find(a => a.id === selectedArticleId), [articles, selectedArticleId]);
  212. const handleSaveArticle = (data: Omit<AIGCArticle, 'id' | 'publicationDate'>) => {
  213. let updatedArticles;
  214. if (selectedArticleId && !selectedArticleId.startsWith('new-')) { // Editing existing article
  215. updatedArticles = articles.map(a => a.id === selectedArticleId ? { ...a, ...data, publicationDate: new Date().toISOString() } : a);
  216. } else { // Creating new article
  217. const newArticle: AIGCArticle = { id: `article-${Date.now()}`, publicationDate: new Date().toISOString(), ...data };
  218. if (selectedArticleId?.startsWith('new-')) {
  219. updatedArticles = articles.map(a => a.id === selectedArticleId ? newArticle : a);
  220. } else {
  221. updatedArticles = [newArticle, ...articles];
  222. }
  223. setSelectedArticleId(newArticle.id);
  224. }
  225. onUpdateAIGCSettings({ ...aigcSettings, articles: updatedArticles });
  226. setIsEditing(false);
  227. };
  228. const handleDeleteArticle = (id: string) => {
  229. if (!window.confirm(t('aigc.news.delete_confirm'))) return;
  230. const updatedArticles = articles.filter(a => a.id !== id);
  231. onUpdateAIGCSettings({ ...aigcSettings, articles: updatedArticles });
  232. if (selectedArticleId === id) {
  233. setSelectedArticleId(null);
  234. setIsEditing(false);
  235. }
  236. };
  237. const handleCreateNewArticle = () => {
  238. if (selectedArticleId?.startsWith('new-')) return; // Don't create another if one is in progress
  239. const tempId = `new-${Date.now()}`;
  240. const tempArticle: AIGCArticle = {
  241. id: tempId,
  242. publicationDate: new Date().toISOString(),
  243. title: '',
  244. summary: '',
  245. content: '',
  246. sourceType: 'text',
  247. };
  248. onUpdateAIGCSettings({ ...aigcSettings, articles: [tempArticle, ...articles] });
  249. setSelectedArticleId(tempId);
  250. setIsEditing(true);
  251. };
  252. const handleCancelEdit = () => {
  253. if (selectedArticleId && selectedArticleId.startsWith('new-')) {
  254. const updatedArticles = articles.filter(a => a.id !== selectedArticleId);
  255. onUpdateAIGCSettings({ ...aigcSettings, articles: updatedArticles });
  256. const firstRealArticle = updatedArticles.find(a => !a.id.startsWith('new-'));
  257. setSelectedArticleId(firstRealArticle ? firstRealArticle.id : null);
  258. }
  259. setIsEditing(false);
  260. };
  261. return (
  262. <div className="flex h-full bg-white dark:bg-gray-800">
  263. <aside className="w-1/3 max-w-sm flex flex-col border-r border-gray-200 dark:border-gray-700">
  264. <header className="p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
  265. <h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">{t('aigc.news.title')}</h2>
  266. <button
  267. onClick={handleCreateNewArticle}
  268. 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"
  269. >
  270. <Icon className="h-5 w-5"><path d="M12 4v16m8-8H4" /></Icon>
  271. {t('aigc.news.create')}
  272. </button>
  273. </header>
  274. <div className="flex-1 overflow-y-auto p-2 space-y-2">
  275. {articles.filter(a => !a.id.startsWith('new-')).sort((a,b) => parseISO(b.publicationDate).getTime() - parseISO(a.publicationDate).getTime()).map(article => (
  276. <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'}`}>
  277. <div className="flex justify-between items-start">
  278. <p className={`font-semibold text-gray-900 dark:text-white truncate ${selectedArticleId === article.id && !isEditing ? 'text-brand-primary' : ''}`}>{article.title}</p>
  279. <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>
  280. </div>
  281. <p className="text-xs text-gray-500 dark:text-gray-400 truncate mt-1">{article.summary}</p>
  282. </button>
  283. ))}
  284. </div>
  285. </aside>
  286. <main className="flex-1">
  287. {selectedArticle ? (
  288. isEditing ? (
  289. <ArticleEditor
  290. initialData={selectedArticle}
  291. onSave={handleSaveArticle}
  292. onCancel={handleCancelEdit}
  293. isNew={selectedArticle.id.startsWith('new-')}
  294. />
  295. ) : (
  296. <ArticlePreview article={selectedArticle} onEdit={() => setIsEditing(true)} />
  297. )
  298. ) : (
  299. <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">
  300. <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>
  301. <h3 className="text-xl font-semibold">{t('aigc.news.empty_title')}</h3>
  302. <p>{t('aigc.news.empty_desc')}</p>
  303. </div>
  304. )}
  305. </main>
  306. </div>
  307. );
  308. };
  309. export default NewsCreator;