Spaces:
Sleeping
Sleeping
| import { useMemo, useState } from 'react' | |
| import FeedPicker from './components/FeedPicker' | |
| import EditorModal from './components/EditorModal' | |
| import TweetCards from './components/TweetCards' | |
| import './App.css' | |
| type Article = { title: string, link: string, summary?: string, published?: string, source?: string } | |
| // Simple spinner component | |
| function Spinner() { | |
| return ( | |
| <div className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]" role="status"> | |
| <span className="!absolute !-m-px !h-px !w-px !overflow-hidden !whitespace-nowrap !border-0 !p-0 ![clip:rect(0,0,0,0)]">Loading...</span> | |
| </div> | |
| ) | |
| } | |
| function App() { | |
| const [sessionId] = useState(() => Math.random().toString(36).slice(2)) | |
| const [apiBase] = useState('/api') | |
| const [sources, setSources] = useState<string[]>([]) | |
| const [articles, setArticles] = useState<Article[]>([]) | |
| const [summary, setSummary] = useState('') | |
| const [tweets, setTweets] = useState<Array<{id: string, content: string, summary_title: string, summary_link: string, summary_source: string}>>([]) | |
| const [newsletterHtml, setNewsletterHtml] = useState('') | |
| const [loadingHighlights, setLoadingHighlights] = useState(false) | |
| const [loadingSummaries, setLoadingSummaries] = useState(false) | |
| const [loadingTweets, setLoadingTweets] = useState(false) | |
| const [loadingNewsletter, setLoadingNewsletter] = useState(false) | |
| const [summariesMode, setSummariesMode] = useState(false) | |
| const [pageIndex, setPageIndex] = useState(0) | |
| const [highlights, setHighlights] = useState<Array<{ title: string, link: string, source?: string, summary: string }>>([]) | |
| const [selectedArticles, setSelectedArticles] = useState<string[]>([]) // Array of article URLs | |
| const [isPaginated, setIsPaginated] = useState(true) // Default to paginated view | |
| const [currentEditingTweetId, setCurrentEditingTweetId] = useState<string | null>(null) | |
| const [tweetConversations, setTweetConversations] = useState<Record<string, Array<{role: string, content: string}>>>({}) | |
| const [pendingTweetUpdate, setPendingTweetUpdate] = useState<string | null>(null) | |
| const hasHighlights = useMemo(() => !!summary, [summary]) | |
| const [editorOpen, setEditorOpen] = useState(false) | |
| const [editorTitle, setEditorTitle] = useState('Editor') | |
| const [editorText, setEditorText] = useState('') | |
| const [editTarget, setEditTarget] = useState<'summary' | `tweet-${number}` | 'newsletter'>('summary') | |
| async function fetchAndSummarize() { | |
| setLoadingHighlights(true) | |
| try { | |
| // 1) Aggregate articles (uses selected sources or backend defaults) | |
| const resAgg = await fetch(`${apiBase}/aggregate`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ sources }) | |
| }) | |
| const dataAgg = await resAgg.json() | |
| // Auto-select all articles by default | |
| const articleUrls = dataAgg.articles.map((a: any) => a.link) | |
| // 2) Try to summarize using the freshly fetched articles | |
| let summaryText = '' | |
| try { | |
| const resSum = await fetch(`${apiBase}/highlights`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ session_id: sessionId, articles: dataAgg.articles }) | |
| }) | |
| const dataSum = await resSum.json() | |
| // Set appropriate message based on whether articles were found | |
| if (dataAgg.articles && dataAgg.articles.length > 0) { | |
| summaryText = dataSum.summary_markdown || 'Highlights generated successfully.' | |
| } else { | |
| summaryText = 'No articles found for the selected sources in the past 7 days.' | |
| } | |
| } catch (summaryError) { | |
| // If summary fails (e.g., no OpenAI key), still show articles and allow progression | |
| console.error('Summary generation failed:', summaryError) | |
| if (dataAgg.articles && dataAgg.articles.length > 0) { | |
| summaryText = 'Articles fetched successfully. Click "Get Summaries" to process selected articles.' | |
| } else { | |
| summaryText = 'No articles found for the selected sources in the past 7 days.' | |
| } | |
| } | |
| // Update all state together after loading is complete | |
| setLoadingHighlights(false) | |
| setArticles(dataAgg.articles) | |
| setSelectedArticles(articleUrls) | |
| setSummariesMode(false) | |
| setSummary(summaryText) | |
| } catch (error) { | |
| setLoadingHighlights(false) | |
| console.error('Failed to fetch articles:', error) | |
| setSummary('Failed to fetch articles. Please try again.') | |
| } | |
| } | |
| async function getHighlights() { | |
| // Validate selection limit before making API call | |
| if (selectedArticles.length > 5) { | |
| alert('Please select 5 or fewer articles for summarization. You currently have ' + selectedArticles.length + ' articles selected.') | |
| return | |
| } | |
| setLoadingSummaries(true) | |
| try { | |
| // Only scrape selected articles | |
| const selectedArticleData = articles.filter(a => selectedArticles.includes(a.link)) | |
| const res = await fetch(`${apiBase}/summaries_selected`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ articles: selectedArticleData }) | |
| }) | |
| const data = await res.json() | |
| const items: { title: string, link: string, source?: string, summary: string }[] = data.items || [] | |
| // Batch all state updates after loading is complete | |
| setLoadingSummaries(false) | |
| setHighlights(items) | |
| setSummariesMode(true) | |
| setPageIndex(0) | |
| } catch (error) { | |
| setLoadingSummaries(false) | |
| throw error | |
| } | |
| } | |
| function resetSummaries() { | |
| setSummariesMode(false) | |
| setSummary('') | |
| setArticles([]) | |
| setTweets([]) | |
| setNewsletterHtml('') | |
| setPageIndex(0) | |
| setHighlights([]) | |
| setSelectedArticles([]) | |
| setIsPaginated(true) // Reset to default paginated view | |
| } | |
| function toggleArticleSelection(articleUrl: string) { | |
| setSelectedArticles(prev => | |
| prev.includes(articleUrl) | |
| ? prev.filter(url => url !== articleUrl) | |
| : [...prev, articleUrl] | |
| ) | |
| } | |
| function selectAllArticles() { | |
| setSelectedArticles(articles.map(a => a.link)) | |
| } | |
| function deselectAllArticles() { | |
| setSelectedArticles([]) | |
| } | |
| async function makeTweets() { | |
| setLoadingTweets(true) | |
| try { | |
| const res = await fetch(`${apiBase}/tweets`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| session_id: sessionId, | |
| summaries: highlights // Send highlights instead of summary_markdown | |
| }) | |
| }) | |
| const data = await res.json() | |
| // Batch state updates after loading is complete | |
| setLoadingTweets(false) | |
| setTweets(data.tweets) | |
| } catch (error) { | |
| setLoadingTweets(false) | |
| throw error | |
| } | |
| } | |
| async function makeNewsletter() { | |
| setLoadingNewsletter(true) | |
| try { | |
| const res = await fetch(`${apiBase}/newsletter`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, summary_markdown: summary, articles }) }) | |
| const data = await res.json() | |
| // Batch state updates after loading is complete | |
| setLoadingNewsletter(false) | |
| setNewsletterHtml(data.html) | |
| } catch (error) { | |
| setLoadingNewsletter(false) | |
| throw error | |
| } | |
| } | |
| function openEditor(target: 'summary' | `tweet-${number}` | 'newsletter') { | |
| setEditTarget(target) | |
| if (target === 'summary') { | |
| setEditorTitle('Edit Summary') | |
| setEditorText(summary) | |
| } else if (target.startsWith('tweet-')) { | |
| const idx = Number(target.split('-')[1]) | |
| setEditorTitle(`Edit Tweet ${idx + 1}`) | |
| setEditorText(tweets[idx]?.content || '') | |
| } else { | |
| setEditorTitle('Edit Newsletter (HTML)') | |
| setEditorText(newsletterHtml) | |
| } | |
| setEditorOpen(true) | |
| } | |
| function openTweetEditor(tweet: {id: string, content: string, summary_title: string, summary_link: string, summary_source: string}) { | |
| // Close any existing editor first, then open the new one | |
| if (currentEditingTweetId === tweet.id) { | |
| setCurrentEditingTweetId(null) | |
| setPendingTweetUpdate(null) | |
| } else { | |
| setCurrentEditingTweetId(tweet.id) | |
| setPendingTweetUpdate(null) | |
| } | |
| } | |
| async function sendTweetMessage(message: string) { | |
| if (!currentEditingTweetId) return | |
| const currentTweet = tweets.find(t => t.id === currentEditingTweetId) | |
| if (!currentTweet) return | |
| try { | |
| const res = await fetch(`${apiBase}/edit_tweet`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| session_id: sessionId, | |
| tweet_id: currentTweet.id, | |
| current_tweet: currentTweet.content, | |
| original_summary: highlights.find(h => h.link === currentTweet.summary_link)?.summary || '', | |
| user_message: message, | |
| conversation_history: tweetConversations[currentTweet.id] || [] | |
| }) | |
| }) | |
| const data = await res.json() | |
| // Store pending update instead of immediately applying it | |
| setPendingTweetUpdate(data.new_tweet) | |
| // Update conversation history | |
| setTweetConversations(prev => ({ | |
| ...prev, | |
| [currentTweet.id]: data.conversation_history | |
| })) | |
| return data.ai_response | |
| } catch (error) { | |
| console.error('Error editing tweet:', error) | |
| return 'Sorry, I encountered an error while processing your request.' | |
| } | |
| } | |
| function acceptTweetUpdate() { | |
| if (!currentEditingTweetId || !pendingTweetUpdate) return | |
| // Update tweet content in main list | |
| const updatedTweets = tweets.map(tweet => | |
| tweet.id === currentEditingTweetId | |
| ? { ...tweet, content: pendingTweetUpdate } | |
| : tweet | |
| ) | |
| setTweets(updatedTweets) | |
| setPendingTweetUpdate(null) | |
| } | |
| function rejectTweetUpdate() { | |
| setPendingTweetUpdate(null) | |
| } | |
| function onEditorDone(newText: string) { | |
| if (editTarget === 'summary') setSummary(newText) | |
| else if (editTarget.startsWith('tweet-')) { | |
| const idx = Number(editTarget.split('-')[1]) | |
| const next = tweets.slice() | |
| if (next[idx]) { | |
| next[idx] = { ...next[idx], content: newText } | |
| setTweets(next) | |
| } | |
| } else setNewsletterHtml(newText) | |
| } | |
| async function download() { | |
| const res = await fetch(`${apiBase}/download_html`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, html: newsletterHtml }) }) | |
| const blob = await res.blob() | |
| const url = URL.createObjectURL(blob) | |
| const a = document.createElement('a') | |
| a.href = url | |
| a.download = 'ai_weekly.html' | |
| document.body.appendChild(a) | |
| a.click() | |
| URL.revokeObjectURL(url) | |
| a.remove() | |
| } | |
| return ( | |
| <div className="min-h-screen bg-gradient-to-b from-indigo-50 to-purple-50 text-gray-900"> | |
| <div className="mx-auto max-w-5xl px-4 py-6"> | |
| <header className="mb-6"> | |
| <div className="flex items-center gap-3"> | |
| <img src="/logo.svg" alt="AI Newsletter" className="h-10 w-10" /> | |
| <div> | |
| <h1 className="text-xl font-semibold bg-gradient-to-r from-indigo-500 to-purple-500 bg-clip-text text-transparent"> | |
| AI Newsletter Generator | |
| </h1> | |
| <p className="text-xs text-gray-500">Curate, summarize, and publish—fast.</p> | |
| </div> | |
| </div> | |
| </header> | |
| <main className="space-y-4"> | |
| <div className="grid grid-cols-1 gap-4 md:grid-cols-3"> | |
| <div className="md:col-span-1"> | |
| <FeedPicker selected={sources} setSelected={setSources} /> | |
| </div> | |
| <div className="md:col-span-2 space-y-3"> | |
| <div className="card p-4"> | |
| <div className="mb-2 flex items-center justify-between"> | |
| <h3 className="text-sm font-semibold">Weekly Highlights</h3> | |
| <div className="flex flex-wrap items-center gap-2"> | |
| {!hasHighlights && !summariesMode && ( | |
| <button className="btn flex items-center gap-2" onClick={fetchAndSummarize} disabled={loadingHighlights}> | |
| {loadingHighlights && <Spinner />} | |
| Get Highlights | |
| </button> | |
| )} | |
| {hasHighlights && !summariesMode && ( | |
| <> | |
| <button className="btn" onClick={resetSummaries}>Reset</button> | |
| <button | |
| className="btn flex items-center gap-2" | |
| onClick={async () => { await getHighlights(); /* switches to summariesMode */ }} | |
| disabled={loadingSummaries || selectedArticles.length === 0} | |
| title={selectedArticles.length === 0 ? "Please select at least one article" : `Process ${selectedArticles.length} selected articles`} | |
| > | |
| {loadingSummaries && <Spinner />} | |
| Get Summaries ({selectedArticles.length}) | |
| </button> | |
| </> | |
| )} | |
| {summariesMode && ( | |
| <> | |
| <button className="btn" onClick={resetSummaries}>Reset</button> | |
| <label className="flex items-center gap-2 text-sm"> | |
| <input | |
| type="checkbox" | |
| checked={isPaginated} | |
| onChange={(e) => { | |
| setIsPaginated(e.target.checked) | |
| setPageIndex(0) // Reset to first page when toggling | |
| }} | |
| className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" | |
| /> | |
| Paginated View | |
| </label> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| {!summariesMode ? ( | |
| <> | |
| {!hasHighlights && ( | |
| summary ? ( | |
| <div className="max-h-96 overflow-y-auto"> | |
| <pre className="whitespace-pre-wrap text-sm text-gray-900">{summary}</pre> | |
| </div> | |
| ) : ( | |
| <div className="text-sm text-gray-500">No highlights yet.</div> | |
| ) | |
| )} | |
| {hasHighlights && articles.length === 0 && ( | |
| <div className="mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg"> | |
| <div className="text-sm text-yellow-800"> | |
| No articles found for the selected sources in the past 7 days. | |
| </div> | |
| </div> | |
| )} | |
| {articles.length > 0 && ( | |
| <div className="mt-4 border-t pt-4"> | |
| <div className="flex items-center justify-between mb-3"> | |
| <h4 className="text-sm font-medium text-gray-700"> | |
| Articles Found ({articles.length}) | |
| </h4> | |
| <div className="flex gap-2"> | |
| <button | |
| className="text-xs text-blue-600 hover:text-blue-800" | |
| onClick={selectAllArticles} | |
| > | |
| Select All | |
| </button> | |
| <button | |
| className="text-xs text-gray-600 hover:text-gray-800" | |
| onClick={deselectAllArticles} | |
| > | |
| Deselect All | |
| </button> | |
| </div> | |
| </div> | |
| <div className="max-h-64 overflow-y-auto space-y-2"> | |
| {articles.map((article, i) => ( | |
| <div key={`${article.link}-${i}`} className="flex items-start gap-3 p-3 border rounded-lg hover:bg-gray-50"> | |
| <input | |
| type="checkbox" | |
| checked={selectedArticles.includes(article.link)} | |
| onChange={() => toggleArticleSelection(article.link)} | |
| className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" | |
| /> | |
| <div className="flex-1 min-w-0"> | |
| <a | |
| href={article.link} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="text-sm font-medium text-blue-600 hover:text-blue-800 block truncate" | |
| > | |
| {article.title} | |
| </a> | |
| <div className="text-xs text-gray-500 mt-1"> | |
| {article.source} {article.published && `• ${article.published}`} | |
| </div> | |
| {article.summary && ( | |
| <div className="text-xs text-gray-600 mt-1 line-clamp-2"> | |
| {article.summary} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| <div className="mt-3 text-xs text-gray-600"> | |
| {selectedArticles.length} of {articles.length} articles selected | |
| </div> | |
| </div> | |
| )} | |
| </> | |
| ) : ( | |
| <> | |
| {isPaginated ? ( | |
| // Paginated view - one summary per page | |
| <div className="space-y-4"> | |
| {highlights.length > 0 && ( | |
| <div className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm"> | |
| <div className="flex items-start justify-between mb-2"> | |
| <a | |
| href={highlights[pageIndex]?.link} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="text-lg font-semibold text-blue-600 hover:text-blue-800 leading-tight" | |
| > | |
| {highlights[pageIndex]?.title} | |
| </a> | |
| </div> | |
| <div className="text-sm text-gray-500 mb-3"> | |
| {highlights[pageIndex]?.source} | |
| </div> | |
| <div className="prose prose-sm max-w-none"> | |
| <div className="text-gray-700 whitespace-pre-wrap">{highlights[pageIndex]?.summary}</div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Pagination controls */} | |
| {highlights.length > 1 && ( | |
| <div className="flex items-center justify-center gap-4 mt-4"> | |
| <button | |
| className="px-3 py-1 border rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50" | |
| onClick={() => setPageIndex(Math.max(0, pageIndex - 1))} | |
| disabled={pageIndex === 0} | |
| > | |
| ← Previous | |
| </button> | |
| <span className="text-sm text-gray-600"> | |
| {pageIndex + 1} of {highlights.length} | |
| </span> | |
| <button | |
| className="px-3 py-1 border rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50" | |
| onClick={() => setPageIndex(Math.min(highlights.length - 1, pageIndex + 1))} | |
| disabled={pageIndex === highlights.length - 1} | |
| > | |
| Next → | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| ) : ( | |
| // List view - all summaries at once (original behavior) | |
| <div className="max-h-96 overflow-y-auto space-y-4"> | |
| {highlights.map((it, i) => ( | |
| <div key={`${it.link}-${i}`} className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm"> | |
| <div className="flex items-start justify-between mb-2"> | |
| <a | |
| href={it.link} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="text-lg font-semibold text-blue-600 hover:text-blue-800 leading-tight" | |
| > | |
| {it.title} | |
| </a> | |
| </div> | |
| <div className="text-sm text-gray-500 mb-3"> | |
| {it.source} | |
| </div> | |
| <div className="prose prose-sm max-w-none"> | |
| <div className="text-gray-700 whitespace-pre-wrap">{it.summary}</div> | |
| </div> | |
| </div> | |
| ))} | |
| <div className="text-center text-sm text-gray-600 pt-2"> | |
| {highlights.length} summaries generated | |
| </div> | |
| </div> | |
| )} | |
| </> | |
| )} | |
| </div> | |
| <div className="card p-4"> | |
| <div className="mb-2 flex items-center justify-between"> | |
| <h3 className="text-sm font-semibold">X/Tweets</h3> | |
| <div className="flex gap-2"> | |
| <button className="btn flex items-center gap-2" onClick={makeTweets} disabled={highlights.length === 0 || loadingTweets}> | |
| {loadingTweets && <Spinner />} | |
| Generate X/Tweets | |
| </button> | |
| </div> | |
| </div> | |
| {tweets.length > 0 ? ( | |
| <TweetCards | |
| tweets={tweets} | |
| onEdit={(tweet) => openTweetEditor(tweet)} | |
| currentEditingTweetId={currentEditingTweetId} | |
| tweetConversations={tweetConversations} | |
| pendingTweetUpdate={pendingTweetUpdate} | |
| highlights={highlights} | |
| onSendMessage={sendTweetMessage} | |
| onAcceptUpdate={acceptTweetUpdate} | |
| onRejectUpdate={rejectTweetUpdate} | |
| onCloseEditor={() => { | |
| setCurrentEditingTweetId(null) | |
| setPendingTweetUpdate(null) | |
| }} | |
| /> | |
| ) : ( | |
| <div className="text-sm text-gray-500">No tweets yet.</div> | |
| )} | |
| </div> | |
| <div className="card p-4"> | |
| <div className="mb-2 flex items-center justify-between"> | |
| <h3 className="text-sm font-semibold">Newsletter</h3> | |
| <div className="flex gap-2"> | |
| <button | |
| className="btn flex items-center gap-2" | |
| onClick={makeNewsletter} | |
| disabled={highlights.length === 0 || loadingNewsletter} | |
| title={highlights.length === 0 ? "Please create summaries first by clicking 'Get Summaries'" : "Generate newsletter from summaries"} | |
| > | |
| {loadingNewsletter && <Spinner />} | |
| Generate | |
| </button> | |
| <button className="btn" onClick={() => openEditor('newsletter')} disabled={!newsletterHtml}>Edit with AI</button> | |
| <button className="btn" onClick={download} disabled={!newsletterHtml}>Download</button> | |
| </div> | |
| </div> | |
| <div className="overflow-hidden rounded-lg border"> | |
| {newsletterHtml ? <iframe srcDoc={newsletterHtml} className="h-[500px] w-full" /> : <div className="p-4 text-sm text-gray-500">No newsletter yet.</div>} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <EditorModal | |
| open={editorOpen} | |
| onClose={() => setEditorOpen(false)} | |
| onDone={onEditorDone} | |
| initialText={editorText} | |
| title={editorTitle} | |
| sessionId={sessionId} | |
| apiBase={apiBase} | |
| /> | |
| </main> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| export default App | |