forked from
j4ck.xyz/tweets2bsky
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 .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>