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