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.
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 3eed065102add1bc498db99badbd5a83407601a1 919 lines 59 kB view raw
1<!DOCTYPE html> 2<html lang="en"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>Tweets-2-Bsky Dashboard</title> 7 <link rel="icon" type="image/svg+xml" href="/favicon.svg"> 8 <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> 9 <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> 10 <style> 11 :root { 12 --primary-color: #0ea5e9; /* Sky blue */ 13 --primary-hover: #0284c7; 14 --bg-light: #f1f5f9; 15 --card-light: #ffffff; 16 --text-light: #334155; 17 --bg-dark: #0f172a; 18 --card-dark: #1e293b; 19 --text-dark: #f1f5f9; 20 --border-radius: 12px; 21 } 22 body { 23 font-family: 'Inter', system-ui, -apple-system, sans-serif; 24 background-color: var(--bg-light); 25 color: var(--text-light); 26 transition: background-color 0.3s, color 0.3s; 27 } 28 [data-bs-theme="dark"] body { 29 background-color: var(--bg-dark); 30 color: var(--text-dark); 31 } 32 33 .navbar { 34 background-color: var(--card-light) !important; 35 box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1); 36 } 37 [data-bs-theme="dark"] .navbar { 38 background-color: var(--card-dark) !important; 39 border-bottom: 1px solid rgba(255,255,255,0.05); 40 } 41 42 .card { 43 border: none; 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); 46 background-color: var(--card-light); 47 margin-bottom: 1.5rem; 48 overflow: hidden; 49 } 50 [data-bs-theme="dark"] .card { 51 background-color: var(--card-dark); 52 box-shadow: none; 53 border: 1px solid rgba(255,255,255,0.05); 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 69 .btn-primary { 70 background-color: var(--primary-color); 71 border: none; 72 font-weight: 500; 73 padding: 0.5rem 1rem; 74 } 75 .btn-primary:hover { background-color: var(--primary-hover); } 76 77 .form-control, .form-select { 78 border-radius: 8px; 79 border-color: #cbd5e1; 80 padding: 0.6rem 0.8rem; 81 } 82 [data-bs-theme="dark"] .form-control, [data-bs-theme="dark"] .form-select { 83 background-color: #334155; 84 border-color: #475569; 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; 105 } 106 [data-bs-theme="dark"] .tweet-preview { color: #94a3b8; } 107 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); 114 color: var(--primary-color); 115 } 116 </style> 117</head> 118<body> 119 <div id="root"></div> 120 121 <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> 122 <script src="https://unpkg.com/react@18/umd/react.production.min.js"></script> 123 <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script> 124 <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script> 125 <script src="https://unpkg.com/axios/dist/axios.min.js"></script> 126 127 <script type="text/babel"> 128 const { useState, useEffect, useCallback } = React; 129 130 function App() { 131 const [token, setToken] = useState(localStorage.getItem('token')); 132 const [darkMode, setDarkMode] = useState(() => localStorage.getItem('darkMode') === 'true'); 133 const [view, setView] = useState(localStorage.getItem('token') ? 'dashboard' : 'login'); 134 const [mappings, setMappings] = useState([]); 135 const [twitterConfig, setTwitterConfig] = useState({ authToken: '', ct0: '' }); 136 const [aiConfig, setAiConfig] = useState({ provider: 'gemini', apiKey: '', model: '', baseUrl: '' }); 137 const [recentActivity, setRecentActivity] = useState([]); 138 const [isAdmin, setIsAdmin] = useState(false); 139 const [status, setStatus] = useState({}); 140 const [loading, setLoading] = useState(false); 141 const [error, setError] = useState(''); 142 const [countdown, setCountdown] = useState(''); 143 const [editingMapping, setEditingMapping] = useState(null); 144 145 const handleLogout = useCallback(() => { 146 localStorage.removeItem('token'); 147 setToken(null); 148 setView('login'); 149 setIsAdmin(false); 150 }, []); 151 152 const fetchStatus = useCallback(async () => { 153 if (!token) return; 154 try { 155 const res = await axios.get('/api/status', { 156 headers: { Authorization: `Bearer ${token}` } 157 }); 158 setStatus(res.data); 159 } catch (err) { 160 if (err.response?.status === 401) handleLogout(); 161 } 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]); 175 176 const fetchData = useCallback(async () => { 177 if (!token) return; 178 try { 179 const headers = { Authorization: `Bearer ${token}` }; 180 const [meRes, mapRes] = await Promise.all([ 181 axios.get('/api/me', { headers }), 182 axios.get('/api/mappings', { headers }) 183 ]); 184 setIsAdmin(meRes.data.isAdmin); 185 setMappings(mapRes.data); 186 if (meRes.data.isAdmin) { 187 const twitRes = await axios.get('/api/twitter-config', { headers }); 188 setTwitterConfig(twitRes.data); 189 const aiRes = await axios.get('/api/ai-config', { headers }); 190 setAiConfig(aiRes.data || { provider: 'gemini', apiKey: '' }); 191 } 192 fetchStatus(); 193 fetchActivity(); 194 } catch (err) { 195 console.error('Failed to fetch data', err); 196 if (err.response?.status === 401) handleLogout(); 197 } 198 }, [token, fetchStatus, fetchActivity, handleLogout]); 199 200 useEffect(() => { 201 document.documentElement.setAttribute('data-bs-theme', darkMode ? 'dark' : 'light'); 202 localStorage.setItem('darkMode', darkMode); 203 }, [darkMode]); 204 205 useEffect(() => { 206 if (token) { 207 fetchData(); 208 setView('dashboard'); 209 } else { 210 setView('login'); 211 } 212 }, [token, fetchData]); 213 214 useEffect(() => { 215 if (view !== 'dashboard' || !token) return; 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]); 223 224 useEffect(() => { 225 if (!status.nextCheckTime) return; 226 const updateCountdown = () => { 227 const diff = status.nextCheckTime - Date.now(); 228 if (diff <= 0) { 229 setCountdown('Checking...'); 230 return; 231 } 232 const mins = Math.floor(diff / 60000); 233 const secs = Math.floor((diff % 60000) / 1000); 234 setCountdown(`${mins}m ${secs}s`); 235 }; 236 updateCountdown(); 237 const timer = setInterval(updateCountdown, 1000); 238 return () => clearInterval(timer); 239 }, [status.nextCheckTime]); 240 241 // Handlers (Login, Register, Add/Edit/Delete Mapping, etc.) 242 const handleLogin = async (e) => { 243 e.preventDefault(); 244 setError(''); 245 const email = e.target.email.value; 246 const password = e.target.password.value; 247 try { 248 const res = await axios.post('/api/login', { email, password }); 249 localStorage.setItem('token', res.data.token); 250 setToken(res.data.token); 251 setIsAdmin(res.data.isAdmin); 252 } catch (err) { 253 setError('Invalid credentials'); 254 } 255 }; 256 257 const handleRegister = async (e) => { 258 e.preventDefault(); 259 setError(''); 260 const email = e.target.email.value; 261 const password = e.target.password.value; 262 try { 263 await axios.post('/api/register', { email, password }); 264 setView('login'); 265 alert('Registration successful! Please login.'); 266 } catch (err) { 267 setError('User already exists'); 268 } 269 }; 270 271 const addMapping = async (e) => { 272 e.preventDefault(); 273 setLoading(true); 274 const formData = new FormData(e.target); 275 try { 276 await axios.post('/api/mappings', { 277 owner: formData.get('owner'), 278 twitterUsernames: formData.get('twitterUsernames'), 279 bskyIdentifier: formData.get('bskyIdentifier'), 280 bskyPassword: formData.get('bskyPassword'), 281 bskyServiceUrl: formData.get('bskyServiceUrl') 282 }, { headers: { Authorization: `Bearer ${token}` } }); 283 fetchData(); 284 e.target.reset(); 285 } catch (err) { 286 alert('Failed to add mapping'); 287 } 288 setLoading(false); 289 }; 290 291 const updateMapping = async (e) => { 292 e.preventDefault(); 293 setLoading(true); 294 const formData = new FormData(e.target); 295 try { 296 await axios.put(`/api/mappings/${editingMapping.id}`, { 297 owner: formData.get('owner'), 298 twitterUsernames: formData.get('twitterUsernames'), 299 bskyIdentifier: formData.get('bskyIdentifier'), 300 bskyPassword: formData.get('bskyPassword'), 301 bskyServiceUrl: formData.get('bskyServiceUrl') 302 }, { headers: { Authorization: `Bearer ${token}` } }); 303 fetchData(); 304 setEditingMapping(null); 305 } catch (err) { 306 alert('Failed to update mapping'); 307 } 308 setLoading(false); 309 }; 310 311 const deleteMapping = async (id) => { 312 if (!confirm('Are you sure?')) return; 313 try { 314 await axios.delete(`/api/mappings/${id}`, { headers: { Authorization: `Bearer ${token}` } }); 315 fetchData(); 316 } catch (err) { 317 alert('Failed to delete'); 318 } 319 }; 320 321 const runNow = async () => { 322 try { 323 await axios.post('/api/run-now', {}, { headers: { Authorization: `Bearer ${token}` } }); 324 fetchStatus(); 325 } catch (err) { 326 alert('Failed to trigger check'); 327 } 328 }; 329 330 const runBackfill = async (id) => { 331 const limit = prompt(`How many tweets to backfill per account?`, "15"); 332 if (limit === null) return; 333 try { 334 await axios.post(`/api/backfill/${id}`, { limit: parseInt(limit) || 15 }, { headers: { Authorization: `Bearer ${token}` } }); 335 fetchStatus(); 336 } catch (err) { 337 alert(err.response?.data?.error || 'Failed to queue backfill'); 338 } 339 }; 340 341 const clearAllBackfills = async () => { 342 if (!confirm('Stop all pending and running backfills?')) return; 343 try { 344 await axios.post('/api/backfill/clear-all', {}, { headers: { Authorization: `Bearer ${token}` } }); 345 fetchStatus(); 346 } catch (err) { 347 alert('Failed to clear backfills'); 348 } 349 }; 350 351 const resetAndBackfill = async (id) => { 352 const limit = prompt(`Reset cache and backfill how many tweets?`, "15"); 353 if (limit === null) return; 354 try { 355 await axios.delete(`/api/mappings/${id}/cache`, { headers: { Authorization: `Bearer ${token}` } }); 356 await axios.post(`/api/backfill/${id}`, { limit: parseInt(limit) || 15 }, { headers: { Authorization: `Bearer ${token}` } }); 357 fetchStatus(); 358 } catch (err) { 359 alert('Reset & Backfill failed'); 360 } 361 }; 362 363 const updateTwitter = async (e) => { 364 e.preventDefault(); 365 const formData = new FormData(e.target); 366 try { 367 await axios.post('/api/twitter-config', { 368 authToken: formData.get('authToken'), 369 ct0: formData.get('ct0'), 370 backupAuthToken: formData.get('backupAuthToken'), 371 backupCt0: formData.get('backupCt0') 372 }, { headers: { Authorization: `Bearer ${token}` } }); 373 alert('Twitter config updated!'); 374 fetchData(); 375 } catch (err) { 376 alert('Failed to update twitter config'); 377 } 378 }; 379 380 const updateAiConfig = async (e) => { 381 e.preventDefault(); 382 const formData = new FormData(e.target); 383 try { 384 await axios.post('/api/ai-config', { 385 provider: formData.get('provider'), 386 apiKey: formData.get('apiKey'), 387 model: formData.get('model'), 388 baseUrl: formData.get('baseUrl') 389 }, { headers: { Authorization: `Bearer ${token}` } }); 390 alert('AI Config updated!'); 391 fetchData(); 392 } catch (err) { 393 alert('Failed to update AI config'); 394 } 395 }; 396 397 const handleExportConfig = async () => { 398 try { 399 const response = await axios.get('/api/config/export', { 400 headers: { Authorization: `Bearer ${token}` }, 401 responseType: 'blob', 402 }); 403 const url = window.URL.createObjectURL(new Blob([response.data])); 404 const link = document.createElement('a'); 405 link.href = url; 406 link.setAttribute('download', `tweets-2-bsky-config-${new Date().toISOString().split('T')[0]}.json`); 407 document.body.appendChild(link); 408 link.click(); 409 link.remove(); 410 } catch (err) { 411 alert('Failed to export configuration'); 412 } 413 }; 414 415 const handleImportConfig = async (e) => { 416 const file = e.target.files[0]; 417 if (!file) return; 418 419 if (!confirm('This will OVERWRITE your current accounts and settings (except login). Are you sure?')) { 420 e.target.value = ''; // Reset input 421 return; 422 } 423 424 const reader = new FileReader(); 425 reader.onload = async (event) => { 426 try { 427 const config = JSON.parse(event.target.result); 428 await axios.post('/api/config/import', config, { 429 headers: { Authorization: `Bearer ${token}` } 430 }); 431 alert('Configuration imported successfully! Reloading...'); 432 fetchData(); 433 } catch (err) { 434 alert('Failed to import configuration: ' + (err.response?.data?.error || err.message)); 435 } 436 e.target.value = ''; // Reset input 437 }; 438 reader.readAsText(file); 439 }; 440 441 if (view === 'login' || view === 'register' || !token) { 442 return ( 443 <div className="container d-flex justify-content-center align-items-center min-vh-100"> 444 <div className="card p-4 border-0 shadow-lg" style={{width: '400px'}}> 445 <div className="text-center mb-4"> 446 <span className="material-icons text-primary" style={{fontSize: '48px'}}>swap_calls</span> 447 <h4 className="mt-2 fw-bold">{view === 'login' ? 'Welcome Back' : 'Get Started'}</h4> 448 </div> 449 {error && <div className="alert alert-danger py-2 small">{error}</div>} 450 <form onSubmit={view === 'login' ? handleLogin : handleRegister}> 451 <div className="mb-3"> 452 <label className="form-label small text-muted text-uppercase fw-bold">Email</label> 453 <input name="email" type="email" className="form-control" required /> 454 </div> 455 <div className="mb-4"> 456 <label className="form-label small text-muted text-uppercase fw-bold">Password</label> 457 <input name="password" type="password" className="form-control" required /> 458 </div> 459 <button type="submit" className="btn btn-primary w-100 mb-3 shadow-sm"> 460 {view === 'login' ? 'Login' : 'Create Account'} 461 </button> 462 <div className="text-center"> 463 <a href="#" className="text-decoration-none small text-muted" onClick={() => setView(view === 'login' ? 'register' : 'login')}> 464 {view === 'login' ? 'Need an account? Register' : 'Have an account? Login'} 465 </a> 466 </div> 467 </form> 468 </div> 469 </div> 470 ); 471 } 472 473 const isBackfillQueued = (id) => status.pendingBackfills?.some(b => (b.id || b) === id); 474 475 // Check if configs are set to collapse by default 476 const hasTwitterConfig = twitterConfig.authToken && twitterConfig.ct0; 477 const hasAiConfig = aiConfig.apiKey; 478 479 return ( 480 <div> 481 <nav className="navbar navbar-expand-lg sticky-top mb-4 py-3"> 482 <div className="container"> 483 <span className="navbar-brand d-flex align-items-center"> 484 <span className="material-icons me-2 text-primary">swap_calls</span> 485 <span className="fw-bold">Tweets-2-Bsky</span> 486 </span> 487 <div className="d-flex align-items-center gap-3"> 488 <div className="d-none d-md-block text-end lh-1 me-2 border-end pe-3"> 489 <div className="small fw-bold text-muted">NEXT RUN</div> 490 <div className="text-primary font-monospace">{countdown}</div> 491 </div> 492 <button className="btn btn-primary btn-sm d-flex align-items-center gap-2" onClick={runNow} title="Run Now"> 493 <span className="material-icons" style={{fontSize: '18px'}}>play_arrow</span> Run Now 494 </button> 495 {isAdmin && status.pendingBackfills?.length > 0 && ( 496 <button className="btn btn-outline-danger btn-sm d-flex align-items-center" onClick={clearAllBackfills} title="Clear Queue"> 497 <span className="material-icons" style={{fontSize: '18px'}}>layers_clear</span> 498 </button> 499 )} 500 <div className="dropdown"> 501 <button className="btn btn-link text-decoration-none text-muted p-0" data-bs-toggle="dropdown"> 502 <span className="material-icons" style={{fontSize: '28px'}}>account_circle</span> 503 </button> 504 <ul className="dropdown-menu dropdown-menu-end border-0 shadow"> 505 <li><button className="dropdown-item d-flex align-items-center" onClick={() => setDarkMode(!darkMode)}> 506 <span className="material-icons me-2 small">{darkMode ? 'light_mode' : 'dark_mode'}</span> Theme 507 </button></li> 508 <li><hr className="dropdown-divider"/></li> 509 <li><button className="dropdown-item d-flex align-items-center text-danger" onClick={handleLogout}> 510 <span className="material-icons me-2 small">logout</span> Logout 511 </button></li> 512 </ul> 513 </div> 514 </div> 515 </div> 516 </nav> 517 518 <div className="container pb-5"> 519 {status.currentStatus && status.currentStatus.state !== 'idle' && ( 520 <div className="card mb-4 border-0 shadow-sm overflow-hidden"> 521 <div className="progress" style={{ height: '4px' }}> 522 <div 523 className={`progress-bar progress-bar-striped progress-bar-animated ${status.currentStatus.state === 'backfilling' ? 'bg-warning' : 'bg-success'}`} 524 style={{ width: `${status.currentStatus.totalCount > 0 ? (status.currentStatus.processedCount / status.currentStatus.totalCount) * 100 : 100}%` }} 525 ></div> 526 </div> 527 <div className="card-body d-flex align-items-center justify-content-between py-3"> 528 <div className="d-flex align-items-center"> 529 <div className={`spinner-border spinner-border-sm me-3 ${status.currentStatus.state === 'backfilling' ? 'text-warning' : 'text-success'}`} role="status"></div> 530 <div> 531 <h6 className="mb-0 text-capitalize fw-bold">{status.currentStatus.state}...</h6> 532 <div className="text-muted small"> 533 {status.currentStatus.currentAccount && <span className="fw-semibold">@{status.currentStatus.currentAccount}</span>} 534 <span className="mx-2"></span> 535 {status.currentStatus.message} 536 </div> 537 </div> 538 </div> 539 {status.currentStatus.totalCount > 0 && ( 540 <div className="text-end"> 541 <div className="h5 mb-0 fw-bold">{Math.round((status.currentStatus.processedCount / status.currentStatus.totalCount) * 100)}%</div> 542 </div> 543 )} 544 </div> 545 </div> 546 )} 547 548 <div className="row g-4"> 549 {/* Main Content Column */} 550 <div className={isAdmin ? "col-lg-8" : "col-12"}> 551 {/* Accounts List */} 552 <div className="card"> 553 <div className="card-header bg-transparent"> 554 <span className="d-flex align-items-center gap-2"> 555 <span className="material-icons text-primary">list</span> Active Accounts 556 </span> 557 <span className="badge bg-secondary bg-opacity-10 text-secondary">{mappings.length} configured</span> 558 </div> 559 <div className="card-body p-0"> 560 {mappings.length === 0 ? ( 561 <div className="text-center py-5 text-muted"> 562 <span className="material-icons" style={{fontSize: '48px', opacity: 0.5}}>inbox</span> 563 <p className="mt-2">No accounts configured yet.</p> 564 </div> 565 ) : ( 566 <div className="table-responsive"> 567 <table className="table align-middle mb-0 table-hover"> 568 <thead className="bg-light"> 569 <tr className="text-uppercase small text-muted"> 570 <th className="fw-bold ps-4">Owner</th> 571 <th className="fw-bold">Twitter Sources</th> 572 <th className="fw-bold">Bluesky Target</th> 573 <th className="fw-bold">Status</th> 574 <th className="text-end fw-bold pe-4">Actions</th> 575 </tr> 576 </thead> 577 <tbody> 578 {mappings.map(m => ( 579 <tr key={m.id}> 580 <td className="ps-4"><span className="fw-bold text-dark">{m.owner || 'System'}</span></td> 581 <td> 582 <div className="d-flex flex-wrap gap-1"> 583 {m.twitterUsernames.map(u => ( 584 <span key={u} className="badge bg-primary bg-opacity-10 text-primary border border-primary border-opacity-10 fw-normal">@{u}</span> 585 ))} 586 </div> 587 </td> 588 <td className="small text-muted fw-medium">{m.bskyIdentifier}</td> 589 <td> 590 <div className="d-flex align-items-center"> 591 <span className={`status-dot ${isBackfillQueued(m.id) ? 'status-queued' : 'status-active'}`}></span> 592 <span className="badge bg-success bg-opacity-10 text-success border border-success border-opacity-10">{isBackfillQueued(m.id) ? 'Backfilling' : 'Active'}</span> 593 </div> 594 </td> 595 <td className="text-end pe-4"> 596 <div className="dropdown"> 597 <button className="btn btn-light btn-sm" data-bs-toggle="dropdown"> 598 <span className="material-icons" style={{fontSize: '18px'}}>more_horiz</span> 599 </button> 600 <ul className="dropdown-menu dropdown-menu-end border-0 shadow"> 601 {isAdmin && ( 602 <> 603 <li><button className="dropdown-item" onClick={() => setEditingMapping(m)}>Edit</button></li> 604 <li><button className="dropdown-item" onClick={() => runBackfill(m.id)}>Backfill History</button></li> 605 <li><button className="dropdown-item text-warning" onClick={() => resetAndBackfill(m.id)}>Reset Cache & Backfill</button></li> 606 <li><hr className="dropdown-divider"/></li> 607 </> 608 )} 609 <li><button className="dropdown-item text-danger" onClick={() => deleteMapping(m.id)}>Delete</button></li> 610 </ul> 611 </div> 612 </td> 613 </tr> 614 ))} 615 </tbody> 616 </table> 617 </div> 618 )} 619 </div> 620 </div> 621 622 {/* Recent Activity */} 623 <div className="card"> 624 <div className="card-header bg-transparent"> 625 <span className="d-flex align-items-center gap-2"> 626 <span className="material-icons text-info">history</span> Recent Activity 627 </span> 628 </div> 629 <div className="card-body p-0"> 630 <div className="table-responsive"> 631 <table className="table align-middle mb-0 table-hover table-sm"> 632 <thead className="bg-light"> 633 <tr className="text-uppercase small text-muted"> 634 <th className="ps-4">Time</th> 635 <th>Twitter User</th> 636 <th>Status</th> 637 <th>Details</th> 638 <th className="text-end pe-4">Links</th> 639 </tr> 640 </thead> 641 <tbody> 642 {recentActivity.map((log, idx) => ( 643 <tr key={idx}> 644 <td className="ps-4 small text-muted" style={{whiteSpace: 'nowrap'}}> 645 {new Date(log.created_at).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} 646 </td> 647 <td><span className="fw-semibold text-primary">@{log.twitter_username}</span></td> 648 <td> 649 {log.status === 'migrated' ? 650 <span className="badge bg-success bg-opacity-10 text-success">Migrated</span> : 651 (log.status === 'skipped' ? 652 <span className="badge bg-secondary bg-opacity-10 text-secondary">Skipped</span> : 653 <span className="badge bg-danger bg-opacity-10 text-danger">Failed</span> 654 ) 655 } 656 </td> 657 <td className="small text-muted tweet-preview" title={log.tweet_text || log.twitter_id}> 658 {log.tweet_text || `ID: ${log.twitter_id}`} 659 </td> 660 <td className="text-end pe-4"> 661 {log.bsky_uri && ( 662 <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"> 663 <span className="material-icons" style={{fontSize: '16px'}}>open_in_new</span> 664 </a> 665 )} 666 </td> 667 </tr> 668 ))} 669 {recentActivity.length === 0 && ( 670 <tr><td colSpan="5" className="text-center py-4 text-muted">No recent activity found.</td></tr> 671 )} 672 </tbody> 673 </table> 674 </div> 675 </div> 676 </div> 677 </div> 678 679 {/* Sidebar Column (Config) */} 680 {isAdmin && ( 681 <div className="col-lg-4"> 682 <div className="card"> 683 <div className="card-header"> 684 <span className="d-flex align-items-center gap-2"> 685 <span className="material-icons text-muted">settings</span> Settings 686 </span> 687 </div> 688 <div className="card-body p-0"> 689 <div className="accordion accordion-flush" id="configAccordion"> 690 {/* Twitter Config */} 691 <div className="accordion-item"> 692 <h2 className="accordion-header"> 693 <button 694 className={`accordion-button ${hasTwitterConfig ? 'collapsed' : ''}`} 695 type="button" 696 data-bs-toggle="collapse" 697 data-bs-target="#twitterConfig" 698 > 699 <div className="d-flex align-items-center w-100 me-3"> 700 <span className="material-icons me-2 small">tag</span> 701 Twitter Auth 702 {hasTwitterConfig && <span className="ms-auto badge bg-success bg-opacity-10 text-success border border-success border-opacity-10">Configured</span>} 703 </div> 704 </button> 705 </h2> 706 <div id="twitterConfig" className={`accordion-collapse collapse ${!hasTwitterConfig ? 'show' : ''}`} data-bs-parent="#configAccordion"> 707 <div className="accordion-body bg-light bg-opacity-50"> 708 <form onSubmit={updateTwitter}> 709 <div className="mb-3"> 710 <label className="form-label small fw-bold text-muted">Primary Auth Token</label> 711 <input name="authToken" defaultValue={twitterConfig.authToken} className="form-control form-control-sm" placeholder="auth_token" required /> 712 </div> 713 <div className="mb-3"> 714 <label className="form-label small fw-bold text-muted">Primary CT0</label> 715 <input name="ct0" defaultValue={twitterConfig.ct0} className="form-control form-control-sm" placeholder="ct0" required /> 716 </div> 717 718 <div className="border-top my-3 pt-3"> 719 <h6 className="small text-uppercase text-muted fw-bold mb-3">Backup Credentials (Optional)</h6> 720 <div className="mb-3"> 721 <label className="form-label small fw-bold text-muted">Backup Auth Token</label> 722 <input name="backupAuthToken" defaultValue={twitterConfig.backupAuthToken} className="form-control form-control-sm" placeholder="auth_token" /> 723 </div> 724 <div className="mb-3"> 725 <label className="form-label small fw-bold text-muted">Backup CT0</label> 726 <input name="backupCt0" defaultValue={twitterConfig.backupCt0} className="form-control form-control-sm" placeholder="ct0" /> 727 </div> 728 </div> 729 730 <button className="btn btn-primary btn-sm w-100">Save Credentials</button> 731 </form> 732 </div> 733 </div> 734 </div> 735 736 {/* AI Config */} 737 <div className="accordion-item"> 738 <h2 className="accordion-header"> 739 <button 740 className={`accordion-button ${hasAiConfig ? 'collapsed' : ''}`} 741 type="button" 742 data-bs-toggle="collapse" 743 data-bs-target="#aiConfig" 744 > 745 <div className="d-flex align-items-center w-100 me-3"> 746 <span className="material-icons me-2 small">smart_toy</span> 747 AI Settings 748 {hasAiConfig && <span className="ms-auto badge bg-success bg-opacity-10 text-success border border-success border-opacity-10">Configured</span>} 749 </div> 750 </button> 751 </h2> 752 <div id="aiConfig" className={`accordion-collapse collapse ${!hasAiConfig ? 'show' : ''}`} data-bs-parent="#configAccordion"> 753 <div className="accordion-body bg-light bg-opacity-50"> 754 <form onSubmit={updateAiConfig}> 755 <div className="mb-2"> 756 <label className="form-label small fw-bold text-muted">Provider</label> 757 <select 758 name="provider" 759 className="form-select form-select-sm" 760 defaultValue={aiConfig.provider} 761 onChange={(e) => setAiConfig({...aiConfig, provider: e.target.value})} 762 > 763 <option value="gemini">Google Gemini</option> 764 <option value="openai">OpenAI / OpenRouter</option> 765 <option value="anthropic">Anthropic</option> 766 <option value="custom">Custom</option> 767 </select> 768 </div> 769 <div className="mb-2"> 770 <label className="form-label small fw-bold text-muted">API Key</label> 771 <input 772 name="apiKey" 773 defaultValue={aiConfig.apiKey} 774 className="form-control form-control-sm" 775 type="password" 776 placeholder="sk-..." 777 /> 778 </div> 779 {(aiConfig.provider !== 'gemini') && ( 780 <> 781 <div className="mb-2"> 782 <label className="form-label small fw-bold text-muted">Model ID</label> 783 <input name="model" defaultValue={aiConfig.model} className="form-control form-control-sm" placeholder="gpt-4o" /> 784 </div> 785 <div className="mb-2"> 786 <label className="form-label small fw-bold text-muted">Base URL</label> 787 <input name="baseUrl" defaultValue={aiConfig.baseUrl} className="form-control form-control-sm" placeholder="https://api..." /> 788 </div> 789 </> 790 )} 791 <button className="btn btn-primary btn-sm w-100 mt-2">Save AI Config</button> 792 </form> 793 </div> 794 </div> 795 </div> 796 797 {/* Add Account */} 798 <div className="accordion-item"> 799 <h2 className="accordion-header"> 800 <button className="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#addAccount"> 801 <div className="d-flex align-items-center w-100 me-3"> 802 <span className="material-icons me-2 small">person_add</span> 803 Add Account 804 </div> 805 </button> 806 </h2> 807 <div id="addAccount" className="accordion-collapse collapse" data-bs-parent="#configAccordion"> 808 <div className="accordion-body bg-light bg-opacity-50"> 809 <form onSubmit={addMapping}> 810 <div className="mb-2"> 811 <input name="owner" placeholder="Owner Name" className="form-control form-control-sm" required /> 812 </div> 813 <div className="mb-2"> 814 <input name="twitterUsernames" placeholder="Twitter User(s)" className="form-control form-control-sm" required /> 815 </div> 816 <div className="mb-2"> 817 <input name="bskyIdentifier" placeholder="Bluesky Handle" className="form-control form-control-sm" required /> 818 </div> 819 <div className="mb-2"> 820 <input name="bskyPassword" type="password" placeholder="App Password" className="form-control form-control-sm" required /> 821 </div> 822 <div className="mb-3"> 823 <input name="bskyServiceUrl" defaultValue="https://bsky.social" className="form-control form-control-sm" /> 824 </div> 825 <button className="btn btn-success btn-sm w-100">Add Account</button> 826 </form> 827 </div> 828 </div> 829 </div> 830 </div> 831 </div> 832 </div> 833 834 {/* Data Management */} 835 <div className="card mt-4"> 836 <div className="card-header"> 837 <span className="d-flex align-items-center gap-2"> 838 <span className="material-icons text-muted">save</span> Data Management 839 </span> 840 </div> 841 <div className="card-body"> 842 <div className="d-grid gap-2"> 843 <button onClick={handleExportConfig} className="btn btn-outline-secondary btn-sm d-flex align-items-center justify-content-center gap-2"> 844 <span className="material-icons" style={{fontSize: '18px'}}>download</span> Export Configuration 845 </button> 846 <div className="position-relative"> 847 <input 848 type="file" 849 accept=".json" 850 className="form-control d-none" 851 id="importConfigInput" 852 onChange={handleImportConfig} 853 /> 854 <button onClick={() => document.getElementById('importConfigInput').click()} className="btn btn-outline-primary btn-sm w-100 d-flex align-items-center justify-content-center gap-2"> 855 <span className="material-icons" style={{fontSize: '18px'}}>upload</span> Import Configuration 856 </button> 857 </div> 858 </div> 859 <div className="form-text small mt-2 text-center"> 860 Exports accounts, Twitter keys, and AI settings. User logins are NOT exported. 861 </div> 862 </div> 863 </div> 864 </div> 865 )} 866 </div> 867 </div> 868 869 {/* Edit Modal */} 870 {editingMapping && ( 871 <div className="modal d-block" style={{backgroundColor: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)'}} tabIndex="-1"> 872 <div className="modal-dialog modal-dialog-centered"> 873 <div className="modal-content border-0 shadow-lg" style={{borderRadius: '16px'}}> 874 <div className="modal-header border-0 pb-0 pt-4 px-4"> 875 <h5 className="modal-title fw-bold">Edit Account</h5> 876 <button type="button" className="btn-close" onClick={() => setEditingMapping(null)}></button> 877 </div> 878 <div className="modal-body p-4"> 879 <form onSubmit={updateMapping}> 880 <div className="mb-3"> 881 <label className="form-label small text-muted fw-bold">Owner</label> 882 <input name="owner" defaultValue={editingMapping.owner} className="form-control" required /> 883 </div> 884 <div className="mb-3"> 885 <label className="form-label small text-muted fw-bold">Twitter Usernames</label> 886 <input name="twitterUsernames" defaultValue={editingMapping.twitterUsernames.join(', ')} className="form-control" required /> 887 <div className="form-text small">Comma separated list of handles</div> 888 </div> 889 <div className="mb-3"> 890 <label className="form-label small text-muted fw-bold">Bluesky Handle</label> 891 <input name="bskyIdentifier" defaultValue={editingMapping.bskyIdentifier} className="form-control" required /> 892 </div> 893 <div className="mb-3"> 894 <label className="form-label small text-muted fw-bold">New App Password</label> 895 <input name="bskyPassword" type="password" className="form-control" placeholder="Leave blank to keep current" /> 896 </div> 897 <div className="mb-4"> 898 <label className="form-label small text-muted fw-bold">Service URL</label> 899 <input name="bskyServiceUrl" defaultValue={editingMapping.bskyServiceUrl} className="form-control" /> 900 </div> 901 <div className="d-grid gap-2"> 902 <button type="submit" className="btn btn-primary" disabled={loading}>{loading ? 'Saving...' : 'Save Changes'}</button> 903 <button type="button" className="btn btn-light" onClick={() => setEditingMapping(null)}>Cancel</button> 904 </div> 905 </form> 906 </div> 907 </div> 908 </div> 909 </div> 910 )} 911 </div> 912 ); 913 } 914 915 const root = ReactDOM.createRoot(document.getElementById('root')); 916 root.render(<App />); 917 </script> 918</body> 919</html>