A simple tool which lets you scrape twitter accounts and crosspost them to bluesky accounts! Comes with a CLI and a webapp for managing profiles! Works with images/videos/link embeds/threads.

Enhancement: Modernize UI, add recent activity log with text preview, and improve logging

jack 60694d85 b6ef9c0f

+469 -261
+359 -229
public/index.html
··· 9 9 <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> 10 10 <style> 11 11 :root { 12 - --primary-color: #0085ff; 13 - --primary-hover: #006bcf; 14 - --bg-light: #f8f9fa; 12 + --primary-color: #0ea5e9; /* Sky blue */ 13 + --primary-hover: #0284c7; 14 + --bg-light: #f1f5f9; 15 15 --card-light: #ffffff; 16 - --text-light: #212529; 16 + --text-light: #334155; 17 17 --bg-dark: #0f172a; 18 18 --card-dark: #1e293b; 19 - --text-dark: #f8fafc; 19 + --text-dark: #f1f5f9; 20 + --border-radius: 12px; 20 21 } 21 22 body { 22 - font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 23 - transition: background-color 0.3s, color 0.3s; 23 + font-family: 'Inter', system-ui, -apple-system, sans-serif; 24 24 background-color: var(--bg-light); 25 25 color: var(--text-light); 26 + transition: background-color 0.3s, color 0.3s; 26 27 } 27 28 [data-bs-theme="dark"] body { 28 29 background-color: var(--bg-dark); 29 30 color: var(--text-dark); 30 31 } 31 32 32 - .navbar { 33 - backdrop-filter: blur(10px); 34 - background-color: rgba(255, 255, 255, 0.8) !important; 35 - border-bottom: 1px solid rgba(0,0,0,0.05); 33 + .navbar { 34 + background-color: var(--card-light) !important; 35 + box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1); 36 36 } 37 37 [data-bs-theme="dark"] .navbar { 38 - background-color: rgba(30, 41, 59, 0.8) !important; 38 + background-color: var(--card-dark) !important; 39 39 border-bottom: 1px solid rgba(255,255,255,0.05); 40 40 } 41 41 42 42 .card { 43 43 border: none; 44 - border-radius: 16px; 45 - box-shadow: 0 4px 20px rgba(0,0,0,0.03); 46 - transition: transform 0.2s, box-shadow 0.2s; 44 + border-radius: var(--border-radius); 45 + box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); 47 46 background-color: var(--card-light); 48 47 margin-bottom: 1.5rem; 48 + overflow: hidden; 49 49 } 50 50 [data-bs-theme="dark"] .card { 51 51 background-color: var(--card-dark); 52 - box-shadow: 0 4px 20px rgba(0,0,0,0.2); 52 + box-shadow: none; 53 + border: 1px solid rgba(255,255,255,0.05); 53 54 } 54 55 56 + .card-header { 57 + background-color: transparent; 58 + border-bottom: 1px solid rgba(0,0,0,0.05); 59 + padding: 1rem 1.5rem; 60 + font-weight: 600; 61 + display: flex; 62 + align-items: center; 63 + justify-content: space-between; 64 + } 65 + [data-bs-theme="dark"] .card-header { 66 + border-bottom-color: rgba(255,255,255,0.05); 67 + } 68 + 55 69 .btn-primary { 56 70 background-color: var(--primary-color); 57 71 border: none; 58 - border-radius: 8px; 59 - padding: 8px 16px; 60 72 font-weight: 500; 73 + padding: 0.5rem 1rem; 61 74 } 62 75 .btn-primary:hover { background-color: var(--primary-hover); } 63 76 64 77 .form-control, .form-select { 65 78 border-radius: 8px; 66 - border: 1px solid #e2e8f0; 67 - padding: 10px 12px; 79 + border-color: #cbd5e1; 80 + padding: 0.6rem 0.8rem; 68 81 } 69 82 [data-bs-theme="dark"] .form-control, [data-bs-theme="dark"] .form-select { 70 83 background-color: #334155; 71 84 border-color: #475569; 72 - color: #f8fafc; 85 + color: #f1f5f9; 86 + } 87 + 88 + .status-badge { 89 + font-size: 0.75rem; 90 + padding: 0.25rem 0.75rem; 91 + border-radius: 9999px; 92 + font-weight: 600; 93 + } 94 + 95 + .table > :not(caption) > * > * { padding: 1rem; } 96 + 97 + .tweet-preview { 98 + max-width: 300px; 99 + white-space: nowrap; 100 + overflow: hidden; 101 + text-overflow: ellipsis; 102 + font-family: monospace; 103 + font-size: 0.9em; 104 + color: #64748b; 73 105 } 74 - [data-bs-theme="dark"] .form-control::placeholder { color: #94a3b8; } 106 + [data-bs-theme="dark"] .tweet-preview { color: #94a3b8; } 75 107 76 - .owner-badge { 77 - background-color: rgba(0, 133, 255, 0.1); 108 + .accordion-button:not(.collapsed) { 109 + background-color: rgba(14, 165, 233, 0.1); 110 + color: var(--primary-color); 111 + } 112 + [data-bs-theme="dark"] .accordion-button:not(.collapsed) { 113 + background-color: rgba(14, 165, 233, 0.2); 78 114 color: var(--primary-color); 79 - padding: 4px 10px; 80 - border-radius: 20px; 81 - font-size: 0.75rem; 82 - font-weight: 600; 83 115 } 84 - 85 - .status-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; margin-right: 8px; } 86 - .status-active { background-color: #10b981; box-shadow: 0 0 10px rgba(16, 185, 129, 0.4); } 87 - .status-queued { background-color: #f59e0b; box-shadow: 0 0 10px rgba(245, 158, 11, 0.4); } 88 - 89 - .table > :not(caption) > * > * { padding: 1rem 0.75rem; border-bottom-color: rgba(0,0,0,0.05); } 90 - [data-bs-theme="dark"] .table > :not(caption) > * > * { border-bottom-color: rgba(255,255,255,0.05); color: var(--text-dark); } 91 116 </style> 92 117 </head> 93 118 <body> 94 119 <div id="root"></div> 95 120 121 + <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> 96 122 <script src="https://unpkg.com/react@18/umd/react.production.min.js"></script> 97 123 <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script> 98 124 <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script> ··· 108 134 const [mappings, setMappings] = useState([]); 109 135 const [twitterConfig, setTwitterConfig] = useState({ authToken: '', ct0: '' }); 110 136 const [aiConfig, setAiConfig] = useState({ provider: 'gemini', apiKey: '', model: '', baseUrl: '' }); 137 + const [recentActivity, setRecentActivity] = useState([]); 111 138 const [isAdmin, setIsAdmin] = useState(false); 112 139 const [status, setStatus] = useState({}); 113 140 const [loading, setLoading] = useState(false); ··· 115 142 const [countdown, setCountdown] = useState(''); 116 143 const [editingMapping, setEditingMapping] = useState(null); 117 144 118 - // ... (keep logic for logout, fetchStatus, etc.) ... 119 145 const handleLogout = useCallback(() => { 120 146 localStorage.removeItem('token'); 121 147 setToken(null); ··· 131 157 }); 132 158 setStatus(res.data); 133 159 } catch (err) { 134 - console.error('Failed to fetch status'); 135 160 if (err.response?.status === 401) handleLogout(); 136 161 } 137 162 }, [token, handleLogout]); 163 + 164 + const fetchActivity = useCallback(async () => { 165 + if (!token) return; 166 + try { 167 + const res = await axios.get('/api/recent-activity?limit=20', { 168 + headers: { Authorization: `Bearer ${token}` } 169 + }); 170 + setRecentActivity(res.data); 171 + } catch (err) { 172 + console.error('Failed to fetch activity'); 173 + } 174 + }, [token]); 138 175 139 176 const fetchData = useCallback(async () => { 140 177 if (!token) return; ··· 153 190 setAiConfig(aiRes.data || { provider: 'gemini', apiKey: '' }); 154 191 } 155 192 fetchStatus(); 193 + fetchActivity(); 156 194 } catch (err) { 157 195 console.error('Failed to fetch data', err); 158 196 if (err.response?.status === 401) handleLogout(); 159 197 } 160 - }, [token, fetchStatus, handleLogout]); 198 + }, [token, fetchStatus, fetchActivity, handleLogout]); 161 199 162 200 useEffect(() => { 163 201 document.documentElement.setAttribute('data-bs-theme', darkMode ? 'dark' : 'light'); ··· 175 213 176 214 useEffect(() => { 177 215 if (view !== 'dashboard' || !token) return; 178 - const statusTimer = setInterval(fetchStatus, 5000); // Faster status poll for better responsiveness 179 - return () => clearInterval(statusTimer); 180 - }, [view, token, fetchStatus]); 216 + const statusTimer = setInterval(fetchStatus, 5000); 217 + const activityTimer = setInterval(fetchActivity, 10000); 218 + return () => { 219 + clearInterval(statusTimer); 220 + clearInterval(activityTimer); 221 + }; 222 + }, [view, token, fetchStatus, fetchActivity]); 181 223 182 224 useEffect(() => { 183 225 if (!status.nextCheckTime) return; ··· 196 238 return () => clearInterval(timer); 197 239 }, [status.nextCheckTime]); 198 240 199 - // ... Auth Handlers ... 241 + // Handlers (Login, Register, Add/Edit/Delete Mapping, etc.) 200 242 const handleLogin = async (e) => { 201 243 e.preventDefault(); 202 244 setError(''); ··· 226 268 } 227 269 }; 228 270 229 - // ... Action Handlers ... 230 271 const addMapping = async (e) => { 231 272 e.preventDefault(); 232 273 setLoading(true); ··· 297 338 } 298 339 }; 299 340 300 - const cancelBackfill = async (id) => { 301 - try { 302 - await axios.delete(`/api/backfill/${id}`, { headers: { Authorization: `Bearer ${token}` } }); 303 - fetchStatus(); 304 - } catch (err) { 305 - alert('Failed to cancel backfill'); 306 - } 307 - }; 308 - 309 341 const clearAllBackfills = async () => { 310 342 if (!confirm('Stop all pending and running backfills?')) return; 311 343 try { ··· 313 345 fetchStatus(); 314 346 } catch (err) { 315 347 alert('Failed to clear backfills'); 316 - } 317 - }; 318 - 319 - const clearCache = async (id) => { 320 - if (!confirm(`Clear processed tweets cache?`)) return; 321 - try { 322 - await axios.delete(`/api/mappings/${id}/cache`, { headers: { Authorization: `Bearer ${token}` } }); 323 - alert(`Cache cleared`); 324 - } catch (err) { 325 - alert('Failed to clear cache'); 326 348 } 327 349 }; 328 350 ··· 356 378 const updateAiConfig = async (e) => { 357 379 e.preventDefault(); 358 380 const formData = new FormData(e.target); 359 - const provider = formData.get('provider'); 360 - 361 381 try { 362 382 await axios.post('/api/ai-config', { 363 - provider: provider, 383 + provider: formData.get('provider'), 364 384 apiKey: formData.get('apiKey'), 365 385 model: formData.get('model'), 366 386 baseUrl: formData.get('baseUrl') ··· 375 395 if (view === 'login' || view === 'register' || !token) { 376 396 return ( 377 397 <div className="container d-flex justify-content-center align-items-center min-vh-100"> 378 - <div className="card p-5" style={{width: '400px'}}> 398 + <div className="card p-4 border-0 shadow-lg" style={{width: '400px'}}> 379 399 <div className="text-center mb-4"> 380 400 <span className="material-icons text-primary" style={{fontSize: '48px'}}>swap_calls</span> 381 - <h3 className="mt-2">{view === 'login' ? 'Welcome Back' : 'Join Us'}</h3> 401 + <h4 className="mt-2 fw-bold">{view === 'login' ? 'Welcome Back' : 'Get Started'}</h4> 382 402 </div> 383 - {error && <div className="alert alert-danger btn-sm">{error}</div>} 403 + {error && <div className="alert alert-danger py-2 small">{error}</div>} 384 404 <form onSubmit={view === 'login' ? handleLogin : handleRegister}> 385 405 <div className="mb-3"> 386 406 <label className="form-label small text-muted text-uppercase fw-bold">Email</label> ··· 394 414 {view === 'login' ? 'Login' : 'Create Account'} 395 415 </button> 396 416 <div className="text-center"> 397 - <a href="#" className="text-decoration-none small" onClick={() => setView(view === 'login' ? 'register' : 'login')}> 417 + <a href="#" className="text-decoration-none small text-muted" onClick={() => setView(view === 'login' ? 'register' : 'login')}> 398 418 {view === 'login' ? 'Need an account? Register' : 'Have an account? Login'} 399 419 </a> 400 420 </div> 401 421 </form> 402 - <div className="text-center mt-4 pt-3 border-top"> 403 - <button className="btn btn-link btn-sm text-muted text-decoration-none" onClick={() => setDarkMode(!darkMode)}> 404 - <span className="material-icons align-middle me-1">{darkMode ? 'light_mode' : 'dark_mode'}</span> 405 - {darkMode ? 'Light Mode' : 'Dark Mode'} 406 - </button> 407 - </div> 408 422 </div> 409 423 </div> 410 424 ); 411 425 } 412 426 413 427 const isBackfillQueued = (id) => status.pendingBackfills?.some(b => (b.id || b) === id); 428 + 429 + // Check if configs are set to collapse by default 430 + const hasTwitterConfig = twitterConfig.authToken && twitterConfig.ct0; 431 + const hasAiConfig = aiConfig.apiKey; 414 432 415 433 return ( 416 434 <div> 417 - <nav className="navbar navbar-expand-lg sticky-top mb-4"> 435 + <nav className="navbar navbar-expand-lg sticky-top mb-4 py-3"> 418 436 <div className="container"> 419 437 <span className="navbar-brand d-flex align-items-center"> 420 438 <span className="material-icons me-2 text-primary">swap_calls</span> 421 439 <span className="fw-bold">Tweets-2-Bsky</span> 422 440 </span> 423 441 <div className="d-flex align-items-center gap-3"> 424 - <div className="d-none d-md-block text-end lh-1 me-2"> 425 - <div className="small fw-bold">Next Check</div> 426 - <div className="text-primary small">{countdown}</div> 442 + <div className="d-none d-md-block text-end lh-1 me-2 border-end pe-3"> 443 + <div className="small fw-bold text-muted">NEXT RUN</div> 444 + <div className="text-primary font-monospace">{countdown}</div> 427 445 </div> 428 - <button className="btn btn-outline-primary btn-sm d-flex align-items-center" onClick={runNow} title="Run Now"> 429 - <span className="material-icons">play_arrow</span> 446 + <button className="btn btn-primary btn-sm d-flex align-items-center gap-2" onClick={runNow} title="Run Now"> 447 + <span className="material-icons" style={{fontSize: '18px'}}>play_arrow</span> Run Now 430 448 </button> 431 449 {isAdmin && status.pendingBackfills?.length > 0 && ( 432 - <button className="btn btn-outline-danger btn-sm" onClick={clearAllBackfills} title="Clear Queue"> 433 - <span className="material-icons">layers_clear</span> 450 + <button className="btn btn-outline-danger btn-sm d-flex align-items-center" onClick={clearAllBackfills} title="Clear Queue"> 451 + <span className="material-icons" style={{fontSize: '18px'}}>layers_clear</span> 434 452 </button> 435 453 )} 436 - <div className="vr mx-1"></div> 437 - <button className="btn btn-link text-decoration-none text-muted" onClick={() => setDarkMode(!darkMode)}> 438 - <span className="material-icons">{darkMode ? 'light_mode' : 'dark_mode'}</span> 439 - </button> 440 - <button className="btn btn-link text-decoration-none text-danger" onClick={handleLogout}> 441 - <span className="material-icons">logout</span> 442 - </button> 454 + <div className="dropdown"> 455 + <button className="btn btn-link text-decoration-none text-muted p-0" data-bs-toggle="dropdown"> 456 + <span className="material-icons" style={{fontSize: '28px'}}>account_circle</span> 457 + </button> 458 + <ul className="dropdown-menu dropdown-menu-end border-0 shadow"> 459 + <li><button className="dropdown-item d-flex align-items-center" onClick={() => setDarkMode(!darkMode)}> 460 + <span className="material-icons me-2 small">{darkMode ? 'light_mode' : 'dark_mode'}</span> Theme 461 + </button></li> 462 + <li><hr className="dropdown-divider"/></li> 463 + <li><button className="dropdown-item d-flex align-items-center text-danger" onClick={handleLogout}> 464 + <span className="material-icons me-2 small">logout</span> Logout 465 + </button></li> 466 + </ul> 467 + </div> 443 468 </div> 444 469 </div> 445 470 </nav> ··· 475 500 )} 476 501 477 502 <div className="row g-4"> 478 - {isAdmin && ( 479 - <div className="col-lg-4"> 480 - <div className="card h-100"> 481 - <div className="card-body"> 482 - <h5 className="card-title mb-4 d-flex align-items-center"> 483 - <span className="material-icons me-2 text-muted">settings</span> Configuration 484 - </h5> 485 - 486 - <form onSubmit={updateTwitter} className="mb-4"> 487 - <h6 className="small text-uppercase text-muted fw-bold mb-3">Twitter Authentication</h6> 488 - <div className="mb-3"> 489 - <input name="authToken" defaultValue={twitterConfig.authToken} className="form-control form-control-sm" placeholder="auth_token" required /> 490 - </div> 491 - <div className="mb-3"> 492 - <input name="ct0" defaultValue={twitterConfig.ct0} className="form-control form-control-sm" placeholder="ct0" required /> 493 - </div> 494 - <button className="btn btn-light btn-sm w-100 border">Save Twitter Config</button> 495 - </form> 496 - 497 - <hr className="my-4" /> 498 - 499 - <form onSubmit={updateAiConfig} className="mb-4"> 500 - <h6 className="small text-uppercase text-muted fw-bold mb-3 d-flex justify-content-between"> 501 - AI Configuration (Alt Text) 502 - <span className="badge bg-light text-dark border">Updated</span> 503 - </h6> 504 - <div className="mb-3"> 505 - <label className="form-label small fw-bold">Provider</label> 506 - <select 507 - name="provider" 508 - className="form-select form-select-sm" 509 - defaultValue={aiConfig.provider} 510 - onChange={(e) => setAiConfig({...aiConfig, provider: e.target.value})} 511 - > 512 - <option value="gemini">Google Gemini (Default)</option> 513 - <option value="openai">OpenAI / OpenRouter</option> 514 - <option value="anthropic">Anthropic (Claude)</option> 515 - <option value="custom">Custom (OpenAI Compatible)</option> 516 - </select> 517 - </div> 518 - <div className="mb-3"> 519 - <label className="form-label small fw-bold">API Key</label> 520 - <input 521 - name="apiKey" 522 - defaultValue={aiConfig.apiKey} 523 - className="form-control form-control-sm" 524 - placeholder={['gemini', 'anthropic'].includes(aiConfig.provider) ? "Required" : "Optional (e.g. for Ollama)"} 525 - required={['gemini', 'anthropic'].includes(aiConfig.provider)} 526 - /> 527 - </div> 528 - 529 - {(aiConfig.provider !== 'gemini') && ( 530 - <> 531 - <div className="mb-3"> 532 - <label className="form-label small fw-bold">Model ID (Optional)</label> 533 - <input name="model" defaultValue={aiConfig.model} className="form-control form-control-sm" placeholder={aiConfig.provider === 'openai' ? 'gpt-4o' : 'claude-3-5-sonnet'} /> 534 - </div> 535 - <div className="mb-3"> 536 - <label className="form-label small fw-bold">Base URL (Optional)</label> 537 - <input name="baseUrl" defaultValue={aiConfig.baseUrl} className="form-control form-control-sm" placeholder="https://api.openai.com/v1" /> 538 - <div className="form-text small">Required for OpenRouter or custom endpoints.</div> 539 - </div> 540 - </> 541 - )} 542 - 543 - <button className="btn btn-light btn-sm w-100 border">Save AI Config</button> 544 - </form> 545 - 546 - <hr className="my-4" /> 547 - 548 - <form onSubmit={addMapping}> 549 - <h6 className="small text-uppercase text-muted fw-bold mb-3">Add New Account</h6> 550 - <div className="mb-2"> 551 - <input name="owner" placeholder="Owner Name" className="form-control form-control-sm" required /> 552 - </div> 553 - <div className="mb-2"> 554 - <input name="twitterUsernames" placeholder="Twitter User(s)" className="form-control form-control-sm" required /> 555 - </div> 556 - <div className="mb-2"> 557 - <input name="bskyIdentifier" placeholder="Bluesky Handle" className="form-control form-control-sm" required /> 558 - </div> 559 - <div className="mb-2"> 560 - <input name="bskyPassword" type="password" placeholder="App Password" className="form-control form-control-sm" required /> 561 - </div> 562 - <div className="mb-3"> 563 - <input name="bskyServiceUrl" defaultValue="https://bsky.social" className="form-control form-control-sm" /> 564 - </div> 565 - <button className="btn btn-primary btn-sm w-100 shadow-sm">Add Mapping</button> 566 - </form> 503 + {/* Main Content Column */} 504 + <div className={isAdmin ? "col-lg-8" : "col-12"}> 505 + {/* Accounts List */} 506 + <div className="card"> 507 + <div className="card-header bg-transparent"> 508 + <span className="d-flex align-items-center gap-2"> 509 + <span className="material-icons text-primary">list</span> Active Accounts 510 + </span> 511 + <span className="badge bg-secondary bg-opacity-10 text-secondary">{mappings.length} configured</span> 567 512 </div> 568 - </div> 569 - </div> 570 - )} 571 - 572 - <div className={isAdmin ? "col-lg-8" : "col-12"}> 573 - <div className="card h-100"> 574 - <div className="card-body"> 575 - <div className="d-flex justify-content-between align-items-center mb-4"> 576 - <h5 className="card-title mb-0 d-flex align-items-center"> 577 - <span className="material-icons me-2 text-muted">list</span> Active Accounts 578 - </h5> 579 - <span className="badge bg-light text-dark border">{mappings.length} configured</span> 580 - </div> 581 - 513 + <div className="card-body p-0"> 582 514 {mappings.length === 0 ? ( 583 515 <div className="text-center py-5 text-muted"> 584 516 <span className="material-icons" style={{fontSize: '48px', opacity: 0.5}}>inbox</span> ··· 586 518 </div> 587 519 ) : ( 588 520 <div className="table-responsive"> 589 - <table className="table align-middle table-hover"> 590 - <thead> 521 + <table className="table align-middle mb-0 table-hover"> 522 + <thead className="bg-light"> 591 523 <tr className="text-uppercase small text-muted"> 592 - <th className="fw-bold">Owner</th> 524 + <th className="fw-bold ps-4">Owner</th> 593 525 <th className="fw-bold">Twitter Sources</th> 594 526 <th className="fw-bold">Bluesky Target</th> 595 527 <th className="fw-bold">Status</th> 596 - <th className="text-end fw-bold">Actions</th> 528 + <th className="text-end fw-bold pe-4">Actions</th> 597 529 </tr> 598 530 </thead> 599 531 <tbody> 600 532 {mappings.map(m => ( 601 533 <tr key={m.id}> 602 - <td><span className="owner-badge">{m.owner || 'System'}</span></td> 534 + <td className="ps-4"><span className="fw-bold text-dark">{m.owner || 'System'}</span></td> 603 535 <td> 604 536 <div className="d-flex flex-wrap gap-1"> 605 537 {m.twitterUsernames.map(u => ( 606 - <span key={u} className="badge bg-secondary bg-opacity-10 text-secondary border border-secondary border-opacity-25 fw-normal">@{u}</span> 538 + <span key={u} className="badge bg-primary bg-opacity-10 text-primary border border-primary border-opacity-10 fw-normal">@{u}</span> 607 539 ))} 608 540 </div> 609 541 </td> ··· 611 543 <td> 612 544 <div className="d-flex align-items-center"> 613 545 <span className={`status-dot ${isBackfillQueued(m.id) ? 'status-queued' : 'status-active'}`}></span> 614 - <span className="small fw-semibold">{isBackfillQueued(m.id) ? 'Backfilling' : 'Active'}</span> 546 + <span className="badge bg-success bg-opacity-10 text-success border border-success border-opacity-10">{isBackfillQueued(m.id) ? 'Backfilling' : 'Active'}</span> 615 547 </div> 616 548 </td> 617 - <td className="text-end"> 618 - <div className="btn-group"> 619 - {isAdmin && ( 620 - <> 621 - <button onClick={() => setEditingMapping(m)} className="btn btn-light btn-sm text-primary" title="Edit"> 622 - <span className="material-icons" style={{fontSize: '18px'}}>edit</span> 623 - </button> 624 - <button onClick={() => runBackfill(m.id)} className="btn btn-light btn-sm text-warning" title="Backfill"> 625 - <span className="material-icons" style={{fontSize: '18px'}}>history</span> 626 - </button> 627 - <button onClick={() => resetAndBackfill(m.id)} className="btn btn-light btn-sm text-danger" title="Reset & Sync"> 628 - <span className="material-icons" style={{fontSize: '18px'}}>restart_alt</span> 629 - </button> 630 - </> 631 - )} 632 - <button onClick={() => deleteMapping(m.id)} className="btn btn-light btn-sm text-danger" title="Delete"> 633 - <span className="material-icons" style={{fontSize: '18px'}}>delete</span> 549 + <td className="text-end pe-4"> 550 + <div className="dropdown"> 551 + <button className="btn btn-light btn-sm" data-bs-toggle="dropdown"> 552 + <span className="material-icons" style={{fontSize: '18px'}}>more_horiz</span> 634 553 </button> 554 + <ul className="dropdown-menu dropdown-menu-end border-0 shadow"> 555 + {isAdmin && ( 556 + <> 557 + <li><button className="dropdown-item" onClick={() => setEditingMapping(m)}>Edit</button></li> 558 + <li><button className="dropdown-item" onClick={() => runBackfill(m.id)}>Backfill History</button></li> 559 + <li><button className="dropdown-item text-warning" onClick={() => resetAndBackfill(m.id)}>Reset Cache & Backfill</button></li> 560 + <li><hr className="dropdown-divider"/></li> 561 + </> 562 + )} 563 + <li><button className="dropdown-item text-danger" onClick={() => deleteMapping(m.id)}>Delete</button></li> 564 + </ul> 635 565 </div> 636 566 </td> 637 567 </tr> ··· 642 572 )} 643 573 </div> 644 574 </div> 575 + 576 + {/* Recent Activity */} 577 + <div className="card"> 578 + <div className="card-header bg-transparent"> 579 + <span className="d-flex align-items-center gap-2"> 580 + <span className="material-icons text-info">history</span> Recent Activity 581 + </span> 582 + </div> 583 + <div className="card-body p-0"> 584 + <div className="table-responsive"> 585 + <table className="table align-middle mb-0 table-hover table-sm"> 586 + <thead className="bg-light"> 587 + <tr className="text-uppercase small text-muted"> 588 + <th className="ps-4">Time</th> 589 + <th>Twitter User</th> 590 + <th>Status</th> 591 + <th>Details</th> 592 + <th className="text-end pe-4">Links</th> 593 + </tr> 594 + </thead> 595 + <tbody> 596 + {recentActivity.map((log, idx) => ( 597 + <tr key={idx}> 598 + <td className="ps-4 small text-muted" style={{whiteSpace: 'nowrap'}}> 599 + {new Date(log.created_at).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} 600 + </td> 601 + <td><span className="fw-semibold text-primary">@{log.twitter_username}</span></td> 602 + <td> 603 + {log.status === 'migrated' ? 604 + <span className="badge bg-success bg-opacity-10 text-success">Migrated</span> : 605 + (log.status === 'skipped' ? 606 + <span className="badge bg-secondary bg-opacity-10 text-secondary">Skipped</span> : 607 + <span className="badge bg-danger bg-opacity-10 text-danger">Failed</span> 608 + ) 609 + } 610 + </td> 611 + <td className="small text-muted tweet-preview" title={log.tweet_text || log.twitter_id}> 612 + {log.tweet_text || `ID: ${log.twitter_id}`} 613 + </td> 614 + <td className="text-end pe-4"> 615 + {log.bsky_uri && ( 616 + <a href={`https://bsky.app/profile/${log.bsky_identifier}/post/${log.bsky_uri.split('/').pop()}`} target="_blank" className="btn btn-link btn-sm p-0 text-decoration-none"> 617 + <span className="material-icons" style={{fontSize: '16px'}}>open_in_new</span> 618 + </a> 619 + )} 620 + </td> 621 + </tr> 622 + ))} 623 + {recentActivity.length === 0 && ( 624 + <tr><td colSpan="5" className="text-center py-4 text-muted">No recent activity found.</td></tr> 625 + )} 626 + </tbody> 627 + </table> 628 + </div> 629 + </div> 630 + </div> 645 631 </div> 632 + 633 + {/* Sidebar Column (Config) */} 634 + {isAdmin && ( 635 + <div className="col-lg-4"> 636 + <div className="card"> 637 + <div className="card-header"> 638 + <span className="d-flex align-items-center gap-2"> 639 + <span className="material-icons text-muted">settings</span> Settings 640 + </span> 641 + </div> 642 + <div className="card-body p-0"> 643 + <div className="accordion accordion-flush" id="configAccordion"> 644 + {/* Twitter Config */} 645 + <div className="accordion-item"> 646 + <h2 className="accordion-header"> 647 + <button 648 + className={`accordion-button ${hasTwitterConfig ? 'collapsed' : ''}`} 649 + type="button" 650 + data-bs-toggle="collapse" 651 + data-bs-target="#twitterConfig" 652 + > 653 + <div className="d-flex align-items-center w-100 me-3"> 654 + <span className="material-icons me-2 small">tag</span> 655 + Twitter Auth 656 + {hasTwitterConfig && <span className="ms-auto badge bg-success bg-opacity-10 text-success border border-success border-opacity-10">Configured</span>} 657 + </div> 658 + </button> 659 + </h2> 660 + <div id="twitterConfig" className={`accordion-collapse collapse ${!hasTwitterConfig ? 'show' : ''}`} data-bs-parent="#configAccordion"> 661 + <div className="accordion-body bg-light bg-opacity-50"> 662 + <form onSubmit={updateTwitter}> 663 + <div className="mb-3"> 664 + <label className="form-label small fw-bold text-muted">Auth Token</label> 665 + <input name="authToken" defaultValue={twitterConfig.authToken} className="form-control form-control-sm" placeholder="auth_token" required /> 666 + </div> 667 + <div className="mb-3"> 668 + <label className="form-label small fw-bold text-muted">CT0</label> 669 + <input name="ct0" defaultValue={twitterConfig.ct0} className="form-control form-control-sm" placeholder="ct0" required /> 670 + </div> 671 + <button className="btn btn-primary btn-sm w-100">Save Credentials</button> 672 + </form> 673 + </div> 674 + </div> 675 + </div> 676 + 677 + {/* AI Config */} 678 + <div className="accordion-item"> 679 + <h2 className="accordion-header"> 680 + <button 681 + className={`accordion-button ${hasAiConfig ? 'collapsed' : ''}`} 682 + type="button" 683 + data-bs-toggle="collapse" 684 + data-bs-target="#aiConfig" 685 + > 686 + <div className="d-flex align-items-center w-100 me-3"> 687 + <span className="material-icons me-2 small">smart_toy</span> 688 + AI Settings 689 + {hasAiConfig && <span className="ms-auto badge bg-success bg-opacity-10 text-success border border-success border-opacity-10">Configured</span>} 690 + </div> 691 + </button> 692 + </h2> 693 + <div id="aiConfig" className={`accordion-collapse collapse ${!hasAiConfig ? 'show' : ''}`} data-bs-parent="#configAccordion"> 694 + <div className="accordion-body bg-light bg-opacity-50"> 695 + <form onSubmit={updateAiConfig}> 696 + <div className="mb-2"> 697 + <label className="form-label small fw-bold text-muted">Provider</label> 698 + <select 699 + name="provider" 700 + className="form-select form-select-sm" 701 + defaultValue={aiConfig.provider} 702 + onChange={(e) => setAiConfig({...aiConfig, provider: e.target.value})} 703 + > 704 + <option value="gemini">Google Gemini</option> 705 + <option value="openai">OpenAI / OpenRouter</option> 706 + <option value="anthropic">Anthropic</option> 707 + <option value="custom">Custom</option> 708 + </select> 709 + </div> 710 + <div className="mb-2"> 711 + <label className="form-label small fw-bold text-muted">API Key</label> 712 + <input 713 + name="apiKey" 714 + defaultValue={aiConfig.apiKey} 715 + className="form-control form-control-sm" 716 + type="password" 717 + placeholder="sk-..." 718 + /> 719 + </div> 720 + {(aiConfig.provider !== 'gemini') && ( 721 + <> 722 + <div className="mb-2"> 723 + <label className="form-label small fw-bold text-muted">Model ID</label> 724 + <input name="model" defaultValue={aiConfig.model} className="form-control form-control-sm" placeholder="gpt-4o" /> 725 + </div> 726 + <div className="mb-2"> 727 + <label className="form-label small fw-bold text-muted">Base URL</label> 728 + <input name="baseUrl" defaultValue={aiConfig.baseUrl} className="form-control form-control-sm" placeholder="https://api..." /> 729 + </div> 730 + </> 731 + )} 732 + <button className="btn btn-primary btn-sm w-100 mt-2">Save AI Config</button> 733 + </form> 734 + </div> 735 + </div> 736 + </div> 737 + 738 + {/* Add Account */} 739 + <div className="accordion-item"> 740 + <h2 className="accordion-header"> 741 + <button className="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#addAccount"> 742 + <div className="d-flex align-items-center w-100 me-3"> 743 + <span className="material-icons me-2 small">person_add</span> 744 + Add Account 745 + </div> 746 + </button> 747 + </h2> 748 + <div id="addAccount" className="accordion-collapse collapse" data-bs-parent="#configAccordion"> 749 + <div className="accordion-body bg-light bg-opacity-50"> 750 + <form onSubmit={addMapping}> 751 + <div className="mb-2"> 752 + <input name="owner" placeholder="Owner Name" className="form-control form-control-sm" required /> 753 + </div> 754 + <div className="mb-2"> 755 + <input name="twitterUsernames" placeholder="Twitter User(s)" className="form-control form-control-sm" required /> 756 + </div> 757 + <div className="mb-2"> 758 + <input name="bskyIdentifier" placeholder="Bluesky Handle" className="form-control form-control-sm" required /> 759 + </div> 760 + <div className="mb-2"> 761 + <input name="bskyPassword" type="password" placeholder="App Password" className="form-control form-control-sm" required /> 762 + </div> 763 + <div className="mb-3"> 764 + <input name="bskyServiceUrl" defaultValue="https://bsky.social" className="form-control form-control-sm" /> 765 + </div> 766 + <button className="btn btn-success btn-sm w-100">Add Account</button> 767 + </form> 768 + </div> 769 + </div> 770 + </div> 771 + </div> 772 + </div> 773 + </div> 774 + </div> 775 + )} 646 776 </div> 647 777 </div> 648 778 ··· 650 780 {editingMapping && ( 651 781 <div className="modal d-block" style={{backgroundColor: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)'}} tabIndex="-1"> 652 782 <div className="modal-dialog modal-dialog-centered"> 653 - <div className="modal-content border-0 shadow"> 654 - <div className="modal-header border-0 pb-0"> 655 - <h5 className="modal-title">Edit Account</h5> 783 + <div className="modal-content border-0 shadow-lg" style={{borderRadius: '16px'}}> 784 + <div className="modal-header border-0 pb-0 pt-4 px-4"> 785 + <h5 className="modal-title fw-bold">Edit Account</h5> 656 786 <button type="button" className="btn-close" onClick={() => setEditingMapping(null)}></button> 657 787 </div> 658 - <div className="modal-body"> 788 + <div className="modal-body p-4"> 659 789 <form onSubmit={updateMapping}> 660 790 <div className="mb-3"> 661 - <label className="form-label small text-uppercase text-muted fw-bold">Owner</label> 791 + <label className="form-label small text-muted fw-bold">Owner</label> 662 792 <input name="owner" defaultValue={editingMapping.owner} className="form-control" required /> 663 793 </div> 664 794 <div className="mb-3"> 665 - <label className="form-label small text-uppercase text-muted fw-bold">Twitter Usernames</label> 795 + <label className="form-label small text-muted fw-bold">Twitter Usernames</label> 666 796 <input name="twitterUsernames" defaultValue={editingMapping.twitterUsernames.join(', ')} className="form-control" required /> 667 - <div className="form-text">Comma separated list of handles</div> 797 + <div className="form-text small">Comma separated list of handles</div> 668 798 </div> 669 799 <div className="mb-3"> 670 - <label className="form-label small text-uppercase text-muted fw-bold">Bluesky Handle</label> 800 + <label className="form-label small text-muted fw-bold">Bluesky Handle</label> 671 801 <input name="bskyIdentifier" defaultValue={editingMapping.bskyIdentifier} className="form-control" required /> 672 802 </div> 673 803 <div className="mb-3"> 674 - <label className="form-label small text-uppercase text-muted fw-bold">New App Password</label> 804 + <label className="form-label small text-muted fw-bold">New App Password</label> 675 805 <input name="bskyPassword" type="password" className="form-control" placeholder="Leave blank to keep current" /> 676 806 </div> 677 807 <div className="mb-4"> 678 - <label className="form-label small text-uppercase text-muted fw-bold">Service URL</label> 808 + <label className="form-label small text-muted fw-bold">Service URL</label> 679 809 <input name="bskyServiceUrl" defaultValue={editingMapping.bskyServiceUrl} className="form-control" /> 680 810 </div> 681 811 <div className="d-grid gap-2"> 682 812 <button type="submit" className="btn btn-primary" disabled={loading}>{loading ? 'Saving...' : 'Save Changes'}</button> 683 - <button type="button" className="btn btn-light text-muted" onClick={() => setEditingMapping(null)}>Cancel</button> 813 + <button type="button" className="btn btn-light" onClick={() => setEditingMapping(null)}>Cancel</button> 684 814 </div> 685 815 </form> 686 816 </div> ··· 696 826 root.render(<App />); 697 827 </script> 698 828 </body> 699 - </html> 829 + </html>
+70 -12
src/db.ts
··· 20 20 const tableInfo = db.prepare('PRAGMA table_info(processed_tweets)').all() as any[]; 21 21 22 22 if (tableInfo.length > 0) { 23 + let schemaChanged = false; 23 24 const hasBskyIdentifier = tableInfo.some((col) => col.name === 'bsky_identifier'); 25 + const hasTweetText = tableInfo.some((col) => col.name === 'tweet_text'); 24 26 25 - if (!hasBskyIdentifier) { 26 - console.log('🔄 Upgrading database schema to support multiple accounts...'); 27 - // SQLite doesn't support easy PK changes, so we recreate the table 27 + if (!hasBskyIdentifier || !hasTweetText) { 28 + console.log('🔄 Upgrading database schema...'); 29 + 30 + // SQLite doesn't support easy PK changes, so we recreate the table if identifier is missing 31 + // Or if we just need to add a column, we can do ALTER TABLE if it's not the PK. 32 + // However, since we might need to do both or one, let's just do the full migration pattern 33 + // to be safe and consistent. 34 + 28 35 db.transaction(() => { 36 + // 1. Rename existing table 37 + db.exec(`ALTER TABLE processed_tweets RENAME TO processed_tweets_old;`); 38 + 39 + // 2. Create new table with all columns 29 40 db.exec(` 30 - ALTER TABLE processed_tweets RENAME TO processed_tweets_old; 31 41 CREATE TABLE processed_tweets ( 32 42 twitter_id TEXT NOT NULL, 33 43 twitter_username TEXT NOT NULL, 34 44 bsky_identifier TEXT NOT NULL, 45 + tweet_text TEXT, 35 46 bsky_uri TEXT, 36 47 bsky_cid TEXT, 37 48 bsky_root_uri TEXT, ··· 40 51 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 41 52 PRIMARY KEY (twitter_id, bsky_identifier) 42 53 ); 43 - -- Copy old data, assuming 'unknown' or trying to infer for bsky_identifier is handled during first run migration 44 - INSERT INTO processed_tweets (twitter_id, twitter_username, bsky_identifier, bsky_uri, bsky_cid, bsky_root_uri, bsky_root_cid, status, created_at) 45 - SELECT twitter_id, twitter_username, 'unknown', bsky_uri, bsky_cid, bsky_root_uri, bsky_root_cid, status, created_at 54 + `); 55 + 56 + // 3. Migrate data 57 + // Handle the case where the old table might not have had bsky_identifier 58 + const oldColumns = tableInfo.map((c) => c.name); 59 + 60 + // Construct the SELECT part based on available old columns 61 + // If old table didn't have bsky_identifier, we default to 'unknown' 62 + const identifierSelect = oldColumns.includes('bsky_identifier') ? 'bsky_identifier' : "'unknown'"; 63 + 64 + // If old table didn't have tweet_text, we default to NULL 65 + const textSelect = oldColumns.includes('tweet_text') ? 'tweet_text' : "NULL"; 66 + 67 + db.exec(` 68 + INSERT INTO processed_tweets ( 69 + twitter_id, 70 + twitter_username, 71 + bsky_identifier, 72 + tweet_text, 73 + bsky_uri, 74 + bsky_cid, 75 + bsky_root_uri, 76 + bsky_root_cid, 77 + status, 78 + created_at 79 + ) 80 + SELECT 81 + twitter_id, 82 + twitter_username, 83 + ${identifierSelect}, 84 + ${textSelect}, 85 + bsky_uri, 86 + bsky_cid, 87 + bsky_root_uri, 88 + bsky_root_cid, 89 + status, 90 + created_at 46 91 FROM processed_tweets_old; 47 - DROP TABLE processed_tweets_old; 48 92 `); 93 + 94 + // 4. Drop old table 95 + db.exec(`DROP TABLE processed_tweets_old;`); 49 96 })(); 50 97 console.log('✅ Database upgraded successfully.'); 51 98 } ··· 56 103 twitter_id TEXT NOT NULL, 57 104 twitter_username TEXT NOT NULL, 58 105 bsky_identifier TEXT NOT NULL, 106 + tweet_text TEXT, 59 107 bsky_uri TEXT, 60 108 bsky_cid TEXT, 61 109 bsky_root_uri TEXT, ··· 76 124 twitter_id: string; 77 125 twitter_username: string; 78 126 bsky_identifier: string; 127 + tweet_text?: string; 79 128 bsky_uri?: string; 80 129 bsky_cid?: string; 81 130 bsky_root_uri?: string; 82 131 bsky_root_cid?: string; 83 132 status: 'migrated' | 'skipped' | 'failed'; 133 + created_at?: string; 84 134 } 85 135 86 136 export const dbService = { ··· 92 142 twitter_id: row.twitter_id, 93 143 twitter_username: row.twitter_username, 94 144 bsky_identifier: row.bsky_identifier, 145 + tweet_text: row.tweet_text, 95 146 bsky_uri: row.bsky_uri, 96 147 bsky_cid: row.bsky_cid, 97 148 bsky_root_uri: row.bsky_root_uri, 98 149 bsky_root_cid: row.bsky_root_cid, 99 150 status: row.status, 151 + created_at: row.created_at 100 152 }; 101 153 }, 102 154 103 155 saveTweet(tweet: ProcessedTweet) { 104 156 const stmt = db.prepare(` 105 157 INSERT OR REPLACE INTO processed_tweets 106 - (twitter_id, twitter_username, bsky_identifier, bsky_uri, bsky_cid, bsky_root_uri, bsky_root_cid, status) 107 - VALUES (?, ?, ?, ?, ?, ?, ?, ?) 158 + (twitter_id, twitter_username, bsky_identifier, tweet_text, bsky_uri, bsky_cid, bsky_root_uri, bsky_root_cid, status) 159 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 108 160 `); 109 161 stmt.run( 110 162 tweet.twitter_id, 111 163 tweet.twitter_username, 112 164 tweet.bsky_identifier, 165 + tweet.tweet_text || null, 113 166 tweet.bsky_uri || null, 114 167 tweet.bsky_cid || null, 115 168 tweet.bsky_root_uri || null, ··· 144 197 cid: row.bsky_cid, 145 198 root: row.bsky_root_uri ? { uri: row.bsky_root_uri, cid: row.bsky_root_cid } : undefined, 146 199 migrated: row.status === 'migrated', 147 - skipped: row.status === 'skipped', 200 + skipped: row.status === 'skipped' 148 201 }; 149 202 } 150 203 return map; 204 + }, 205 + 206 + getRecentProcessedTweets(limit = 50): ProcessedTweet[] { 207 + const stmt = db.prepare('SELECT * FROM processed_tweets ORDER BY created_at DESC LIMIT ?'); 208 + return stmt.all(limit) as ProcessedTweet[]; 151 209 }, 152 210 153 211 deleteTweetsByUsername(username: string) { ··· 165 223 clearAll() { 166 224 db.prepare('DELETE FROM processed_tweets').run(); 167 225 }, 168 - }; 226 + };
+34 -20
src/index.ts
··· 31 31 root?: { uri: string; cid: string }; 32 32 migrated?: boolean; 33 33 skipped?: boolean; 34 + text?: string; 34 35 } 35 36 36 37 interface ProcessedTweetsMap { ··· 181 182 twitter_id: twitterId, 182 183 twitter_username: twitterUsername.toLowerCase(), 183 184 bsky_identifier: bskyIdentifier.toLowerCase(), 185 + tweet_text: entry.text, 184 186 bsky_uri: entry.uri, 185 187 bsky_cid: entry.cid, 186 188 bsky_root_uri: entry.root?.uri, ··· 738 740 tweets: Tweet[], 739 741 dryRun = false, 740 742 ): Promise<void> { 743 + // Filter tweets to ensure they're actually from this user 744 + const filteredTweets = tweets.filter((t) => { 745 + const authorScreenName = t.user?.screen_name?.toLowerCase(); 746 + if (authorScreenName && authorScreenName !== twitterUsername.toLowerCase()) { 747 + console.log( 748 + `[${twitterUsername}] ⏩ Skipping tweet ${t.id_str || t.id} - author is @${t.user?.screen_name}, not @${twitterUsername}`, 749 + ); 750 + return false; 751 + } 752 + return true; 753 + }); 754 + 741 755 const processedTweets = loadProcessedTweets(bskyIdentifier); 742 - const toProcess = tweets.filter(t => !processedTweets[t.id_str || t.id || '']); 743 - 756 + const toProcess = filteredTweets.filter((t) => !processedTweets[t.id_str || t.id || '']); 757 + 744 758 if (toProcess.length === 0) { 745 759 console.log(`[${twitterUsername}] ✅ No new tweets to process for ${bskyIdentifier}.`); 746 760 return; 747 761 } 748 762 749 763 console.log(`[${twitterUsername}] 🚀 Processing ${toProcess.length} new tweets for ${bskyIdentifier}...`); 750 - 751 - tweets.reverse(); 764 + 765 + filteredTweets.reverse(); 752 766 let count = 0; 753 - for (const tweet of tweets) { 767 + for (const tweet of filteredTweets) { 754 768 count++; 755 769 const tweetId = tweet.id_str || tweet.id; 756 770 if (!tweetId) continue; 757 771 758 772 if (processedTweets[tweetId]) continue; 759 773 760 - console.log(`\n[${twitterUsername}] 🕒 Processing tweet: ${tweetId}`); 774 + console.log(`\n[${twitterUsername}] 🔍 Inspecting tweet: ${tweetId}`); 761 775 updateAppStatus({ 762 776 state: 'processing', 763 777 currentAccount: twitterUsername, 764 778 processedCount: count, 765 - totalCount: tweets.length, 766 - message: `Processing tweet ${tweetId}`, 779 + totalCount: filteredTweets.length, 780 + message: `Inspecting tweet ${tweetId}`, 767 781 }); 768 782 769 783 const replyStatusId = tweet.in_reply_to_status_id_str || tweet.in_reply_to_status_id; ··· 780 794 } else { 781 795 console.log(`[${twitterUsername}] ⏩ Skipping external/unknown reply.`); 782 796 if (!dryRun) { 783 - saveProcessedTweet(twitterUsername, bskyIdentifier, tweetId, { skipped: true }); 797 + saveProcessedTweet(twitterUsername, bskyIdentifier, tweetId, { skipped: true, text: tweetText }); 784 798 } 785 799 continue; 786 800 } ··· 1082 1096 } 1083 1097 } 1084 1098 1085 - const currentPostInfo = { 1086 - uri: response.uri, 1087 - cid: response.cid, 1088 - root: postRecord.reply ? postRecord.reply.root : { uri: response.uri, cid: response.cid }, 1089 - }; 1090 - 1091 - if (i === 0) { 1092 - saveProcessedTweet(twitterUsername, bskyIdentifier, tweetId, currentPostInfo); 1093 - } 1099 + const currentPostInfo = { 1100 + uri: response.uri, 1101 + cid: response.cid, 1102 + root: postRecord.reply ? postRecord.reply.root : { uri: response.uri, cid: response.cid }, 1103 + text: tweetText 1104 + }; 1094 1105 1095 - lastPostInfo = currentPostInfo; 1096 - console.log(`[${twitterUsername}] ✅ Chunk ${i + 1} posted successfully.`); 1106 + if (i === 0) { 1107 + saveProcessedTweet(twitterUsername, bskyIdentifier, tweetId, currentPostInfo); 1108 + } 1109 + 1110 + lastPostInfo = currentPostInfo; console.log(`[${twitterUsername}] ✅ Chunk ${i + 1} posted successfully.`); 1097 1111 1098 1112 if (chunks.length > 1) { 1099 1113 await new Promise((r) => setTimeout(r, 3000));
+6
src/server.ts
··· 301 301 res.json({ success: true, message: 'All backfills cleared' }); 302 302 }); 303 303 304 + app.get('/api/recent-activity', authenticateToken, (req, res) => { 305 + const limit = req.query.limit ? Number(req.query.limit) : 50; 306 + const tweets = dbService.getRecentProcessedTweets(limit); 307 + res.json(tweets); 308 + }); 309 + 304 310 // Export for use by index.ts 305 311 export function updateLastCheckTime() { 306 312 const config = getConfig();