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