+31
index.html
+31
index.html
···
7
7
<title>ATproto Migrator</title>
8
8
</head>
9
9
<body>
10
+
<noscript>
11
+
<div style="
12
+
display: flex;
13
+
align-items: center;
14
+
justify-content: center;
15
+
min-height: 100vh;
16
+
padding: 1rem;
17
+
text-align: center;
18
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
19
+
background-color: #f3f4f6;
20
+
color: #1f2937;
21
+
">
22
+
<div style="
23
+
max-width: 28rem;
24
+
padding: 2rem;
25
+
background-color: white;
26
+
border-radius: 0.5rem;
27
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
28
+
">
29
+
<h1 style="
30
+
font-size: 1.5rem;
31
+
font-weight: 600;
32
+
margin-bottom: 1rem;
33
+
">JavaScript Required</h1>
34
+
<p style="
35
+
margin-bottom: 1rem;
36
+
color: #4b5563;
37
+
">This application requires JavaScript to function. Please enable JavaScript in your browser settings to continue.</p>
38
+
</div>
39
+
</div>
40
+
</noscript>
10
41
<div id="root"></div>
11
42
<script type="module" src="/src/main.tsx"></script>
12
43
</body>
+114
-46
src/App.tsx
+114
-46
src/App.tsx
···
1
1
import { useState, useEffect } from 'react'
2
-
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
2
+
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom'
3
3
import { AtpAgent } from '@atproto/api'
4
4
import { AvatarProvider } from './contexts/AvatarContext'
5
+
import { NetworkProvider } from './contexts/NetworkContext'
6
+
import NetworkWarning from './components/common/NetworkWarning'
5
7
import Login from './components/auth/Login'
6
8
import Actions from './components/common/Actions'
7
9
import Migration from './components/common/Migration'
10
+
import MigrationProcess from './components/common/MigrationProcess'
8
11
import RecoveryKey from './components/common/RecoveryKey'
12
+
import RecoveryKeyProcess from './components/common/RecoveryKeyProcess'
9
13
import './styles/App.css'
10
14
11
15
const SESSION_KEY = 'atproto_session';
12
16
const SESSION_EXPIRY = 60 * 60 * 1000; // 1 hour in milliseconds
13
17
18
+
function AppRoutes({ agent, onLogout, handleLogin }: {
19
+
agent: AtpAgent | null;
20
+
onLogout: () => void;
21
+
handleLogin: (agent: AtpAgent) => void;
22
+
}) {
23
+
const location = useLocation();
24
+
25
+
useEffect(() => {
26
+
const checkSession = async () => {
27
+
if (agent) {
28
+
try {
29
+
// Try to make a simple API call to verify the session
30
+
await agent.getProfile({ actor: agent.session?.handle || '' });
31
+
} catch (err) {
32
+
// If the API call fails, the session is likely invalid
33
+
onLogout();
34
+
alert('Your session has expired. Please log in again.');
35
+
}
36
+
}
37
+
};
38
+
39
+
checkSession();
40
+
}, [location.pathname, agent, onLogout]);
41
+
42
+
return (
43
+
<>
44
+
<NetworkWarning />
45
+
<Routes>
46
+
<Route
47
+
path="/"
48
+
element={
49
+
agent ? (
50
+
<Navigate to="/actions" replace />
51
+
) : (
52
+
<Login onLogin={handleLogin} />
53
+
)
54
+
}
55
+
/>
56
+
<Route
57
+
path="/actions"
58
+
element={
59
+
agent ? (
60
+
<Actions agent={agent} onLogout={onLogout} />
61
+
) : (
62
+
<Navigate to="/" replace />
63
+
)
64
+
}
65
+
/>
66
+
<Route
67
+
path="/migration"
68
+
element={
69
+
agent ? (
70
+
<Migration agent={agent} onLogout={onLogout} />
71
+
) : (
72
+
<Navigate to="/" replace />
73
+
)
74
+
}
75
+
/>
76
+
<Route
77
+
path="/migration/process"
78
+
element={
79
+
agent ? (
80
+
<MigrationProcess agent={agent} onLogout={onLogout} />
81
+
) : (
82
+
<Navigate to="/" replace />
83
+
)
84
+
}
85
+
/>
86
+
<Route
87
+
path="/recovery-key"
88
+
element={
89
+
agent ? (
90
+
<RecoveryKey agent={agent} onLogout={onLogout} />
91
+
) : (
92
+
<Navigate to="/" replace />
93
+
)
94
+
}
95
+
/>
96
+
<Route
97
+
path="/recovery-key/process"
98
+
element={
99
+
agent ? (
100
+
<RecoveryKeyProcess agent={agent} onLogout={onLogout} />
101
+
) : (
102
+
<Navigate to="/" replace />
103
+
)
104
+
}
105
+
/>
106
+
</Routes>
107
+
</>
108
+
);
109
+
}
110
+
14
111
function App() {
15
112
const [agent, setAgent] = useState<AtpAgent | null>(null)
16
113
···
49
146
const handleLogout = () => {
50
147
setAgent(null);
51
148
localStorage.removeItem(SESSION_KEY);
149
+
// Clear avatar URL from context
150
+
const avatarContext = document.querySelector('[data-avatar-context]');
151
+
if (avatarContext) {
152
+
const event = new CustomEvent('clearAvatar');
153
+
avatarContext.dispatchEvent(event);
154
+
}
52
155
};
53
156
54
157
return (
55
-
<AvatarProvider>
56
-
<Router>
57
-
<Routes>
58
-
<Route
59
-
path="/"
60
-
element={
61
-
agent ? (
62
-
<Navigate to="/actions" replace />
63
-
) : (
64
-
<Login onLogin={handleLogin} />
65
-
)
66
-
}
67
-
/>
68
-
<Route
69
-
path="/actions"
70
-
element={
71
-
agent ? (
72
-
<Actions agent={agent} onLogout={handleLogout} />
73
-
) : (
74
-
<Navigate to="/" replace />
75
-
)
76
-
}
77
-
/>
78
-
<Route
79
-
path="/migration"
80
-
element={
81
-
agent ? (
82
-
<Migration agent={agent} onLogout={handleLogout} />
83
-
) : (
84
-
<Navigate to="/" replace />
85
-
)
86
-
}
87
-
/>
88
-
<Route
89
-
path="/recovery-key"
90
-
element={
91
-
agent ? (
92
-
<RecoveryKey agent={agent} onLogout={handleLogout} />
93
-
) : (
94
-
<Navigate to="/" replace />
95
-
)
96
-
}
158
+
<NetworkProvider>
159
+
<AvatarProvider>
160
+
<Router>
161
+
<AppRoutes
162
+
agent={agent}
163
+
onLogout={handleLogout}
164
+
handleLogin={handleLogin}
97
165
/>
98
-
</Routes>
99
-
</Router>
100
-
</AvatarProvider>
166
+
</Router>
167
+
</AvatarProvider>
168
+
</NetworkProvider>
101
169
)
102
170
}
103
171
+4
-6
src/components/common/Actions.tsx
+4
-6
src/components/common/Actions.tsx
···
107
107
<section>
108
108
<h2>Account Details</h2>
109
109
<dl>
110
-
<dt>Handle</dt>
111
-
<dd>{agent.session?.handle || 'N/A'}</dd>
112
-
113
-
<dt>PDS Host</dt>
114
-
<dd>{agent.serviceUrl.toString() || 'N/A'}</dd>
115
-
116
110
<dt>DID</dt>
117
111
<dd>{agent.session?.did || 'N/A'}</dd>
112
+
<dt>Handle</dt>
113
+
<dd>@{agent.session?.handle || 'N/A'}</dd>
114
+
<dt>PDS</dt>
115
+
<dd>{agent.serviceUrl.toString() || 'N/A'}</dd>
118
116
</dl>
119
117
</section>
120
118
+14
-6
src/components/common/Migration.tsx
+14
-6
src/components/common/Migration.tsx
···
50
50
</ul>
51
51
</div>
52
52
53
-
<button
54
-
className="back-button"
55
-
onClick={() => navigate('/actions')}
56
-
>
57
-
← Go back
58
-
</button>
53
+
<div className="button-container">
54
+
<button
55
+
className="back-button"
56
+
onClick={() => navigate('/actions')}
57
+
>
58
+
← Go back
59
+
</button>
60
+
<button
61
+
className="continue-button"
62
+
onClick={() => navigate('/migration/process')}
63
+
>
64
+
Continue →
65
+
</button>
66
+
</div>
59
67
</div>
60
68
</div>
61
69
<Footer />
+377
src/components/common/MigrationProcess.tsx
+377
src/components/common/MigrationProcess.tsx
···
1
+
import { useNavigate } from 'react-router-dom';
2
+
import { useState, useEffect, useCallback } from 'react';
3
+
import { AtpAgent } from '@atproto/api';
4
+
import Footer from '../layout/Footer';
5
+
import Header from '../layout/Header';
6
+
import '../../styles/App.css';
7
+
8
+
interface MigrationProcessProps {
9
+
agent: AtpAgent;
10
+
onLogout: () => void;
11
+
}
12
+
13
+
interface PDSInfo {
14
+
exists: boolean;
15
+
requiresInvite: boolean;
16
+
domain: string;
17
+
availableUserDomains: string[];
18
+
}
19
+
20
+
interface AccountDetails {
21
+
handle: string;
22
+
email: string;
23
+
password: string;
24
+
}
25
+
26
+
export default function MigrationProcess({ agent, onLogout }: MigrationProcessProps) {
27
+
const navigate = useNavigate();
28
+
const [pds, setPds] = useState('');
29
+
const [pdsInfo, setPdsInfo] = useState<PDSInfo | null>(null);
30
+
const [isValidating, setIsValidating] = useState(false);
31
+
const [error, setError] = useState('');
32
+
const [inviteCode, setInviteCode] = useState('');
33
+
const [isInviteValid, setIsInviteValid] = useState(false);
34
+
const [showAccountForm, setShowAccountForm] = useState(false);
35
+
const [accountDetails, setAccountDetails] = useState<AccountDetails>({
36
+
handle: '',
37
+
email: '',
38
+
password: ''
39
+
});
40
+
const [isCustomHandle, setIsCustomHandle] = useState(false);
41
+
const [currentHandle, setCurrentHandle] = useState('');
42
+
43
+
// Add warning when trying to close or navigate away and clean up expired data
44
+
useEffect(() => {
45
+
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
46
+
// Clean up expired data
47
+
const savedDetails = localStorage.getItem('migration_details');
48
+
if (savedDetails) {
49
+
const { expiryTime } = JSON.parse(savedDetails);
50
+
if (Date.now() >= expiryTime) {
51
+
localStorage.removeItem('migration_details');
52
+
}
53
+
}
54
+
55
+
e.preventDefault();
56
+
e.returnValue = '';
57
+
return '';
58
+
};
59
+
60
+
window.addEventListener('beforeunload', handleBeforeUnload);
61
+
62
+
return () => {
63
+
window.removeEventListener('beforeunload', handleBeforeUnload);
64
+
// Clean up expired data when component unmounts
65
+
const savedDetails = localStorage.getItem('migration_details');
66
+
if (savedDetails) {
67
+
const { expiryTime } = JSON.parse(savedDetails);
68
+
if (Date.now() >= expiryTime) {
69
+
localStorage.removeItem('migration_details');
70
+
}
71
+
}
72
+
};
73
+
}, []);
74
+
75
+
// Get current user's handle and check if it's a default handle
76
+
useEffect(() => {
77
+
const checkCurrentHandle = async () => {
78
+
try {
79
+
const session = agent.session;
80
+
if (session?.handle) {
81
+
setCurrentHandle(session.handle);
82
+
}
83
+
} catch (err) {
84
+
console.error('Failed to get current handle:', err);
85
+
}
86
+
};
87
+
checkCurrentHandle();
88
+
}, [agent]);
89
+
90
+
// Debounced PDS validation
91
+
const validatePDS = useCallback(async (pdsUrl: string) => {
92
+
if (!pdsUrl) {
93
+
setPdsInfo(null);
94
+
setError('');
95
+
return;
96
+
}
97
+
98
+
setIsValidating(true);
99
+
setError('');
100
+
101
+
try {
102
+
// Ensure the URL has the correct format
103
+
if (!pdsUrl.startsWith('http://') && !pdsUrl.startsWith('https://')) {
104
+
pdsUrl = 'https://' + pdsUrl;
105
+
}
106
+
107
+
// Check if the PDS is a Bluesky PDS
108
+
const hostname = new URL(pdsUrl).hostname;
109
+
if (hostname === 'bsky.social' || hostname === 'bsky.app' || hostname.endsWith('bsky.network')) {
110
+
setPdsInfo({
111
+
exists: false,
112
+
requiresInvite: false,
113
+
domain: hostname,
114
+
availableUserDomains: []
115
+
});
116
+
setError('Bluesky currently does not support migrating accounts to their data servers.');
117
+
return;
118
+
}
119
+
120
+
// Create a temporary agent to check the PDS
121
+
const tempAgent = new AtpAgent({ service: pdsUrl });
122
+
123
+
try {
124
+
// Try to get the server info
125
+
const info = await tempAgent.api.com.atproto.server.describeServer();
126
+
const domain = new URL(pdsUrl).hostname;
127
+
128
+
setPdsInfo({
129
+
exists: true,
130
+
requiresInvite: info.data.inviteCodeRequired || false,
131
+
domain,
132
+
availableUserDomains: info.data.availableUserDomains || []
133
+
});
134
+
} catch (err) {
135
+
setPdsInfo({
136
+
exists: false,
137
+
requiresInvite: false,
138
+
domain: '',
139
+
availableUserDomains: []
140
+
});
141
+
setError('Could not connect to the specified PDS. Please check the URL and try again.');
142
+
}
143
+
} catch (err) {
144
+
setError('Invalid PDS URL format. Please enter a valid URL.');
145
+
} finally {
146
+
setIsValidating(false);
147
+
}
148
+
}, []);
149
+
150
+
// Debounce the validation
151
+
useEffect(() => {
152
+
const timeoutId = setTimeout(() => {
153
+
if (pds) {
154
+
validatePDS(pds);
155
+
}
156
+
}, 500);
157
+
158
+
return () => clearTimeout(timeoutId);
159
+
}, [pds, validatePDS]);
160
+
161
+
// Validate invite code when it changes
162
+
useEffect(() => {
163
+
if (pdsInfo?.requiresInvite && inviteCode) {
164
+
const inviteRegex = /^bsky-noob-quest-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}$/;
165
+
setIsInviteValid(inviteRegex.test(inviteCode));
166
+
}
167
+
}, [inviteCode, pdsInfo]);
168
+
169
+
// Check if handle is custom
170
+
useEffect(() => {
171
+
if (accountDetails.handle && pdsInfo?.availableUserDomains?.length) {
172
+
const defaultDomain = pdsInfo.availableUserDomains[0];
173
+
const handleRegex = new RegExp(`^[a-zA-Z0-9._-]+@${defaultDomain}$`);
174
+
const isDefaultHandle = handleRegex.test(accountDetails.handle);
175
+
setIsCustomHandle(!isDefaultHandle);
176
+
}
177
+
}, [accountDetails.handle, pdsInfo]);
178
+
179
+
// Auto-scroll to latest step
180
+
useEffect(() => {
181
+
const formSection = document.querySelector('.form-section:not(.completed)');
182
+
if (formSection) {
183
+
formSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
184
+
}
185
+
}, [showAccountForm]);
186
+
187
+
// Check if current handle is default
188
+
const isCurrentHandleDefault = useCallback(() => {
189
+
if (!currentHandle || !pdsInfo?.availableUserDomains?.length) return false;
190
+
191
+
// If migrating from Bluesky PDS (bsky.network), check if handle is from bsky.social
192
+
if (agent.serviceUrl.host.endsWith('.bsky.network')) {
193
+
return currentHandle.endsWith('.bsky.social');
194
+
}
195
+
196
+
// For third-party PDS, check if handle ends with any of the available user domains
197
+
return pdsInfo.availableUserDomains.some(domain =>
198
+
currentHandle.endsWith(`${domain}`)
199
+
);
200
+
}, [currentHandle, pdsInfo]);
201
+
202
+
const handlePdsBlur = () => {
203
+
if (pds) {
204
+
validatePDS(pds);
205
+
}
206
+
};
207
+
208
+
const handleContinue = () => {
209
+
if (pdsInfo?.exists && (!pdsInfo.requiresInvite || isInviteValid)) {
210
+
setShowAccountForm(true);
211
+
}
212
+
};
213
+
214
+
const handleStartMigration = () => {
215
+
// Clean up any existing expired data first
216
+
const savedDetails = localStorage.getItem('migration_details');
217
+
if (savedDetails) {
218
+
const { expiryTime } = JSON.parse(savedDetails);
219
+
if (Date.now() >= expiryTime) {
220
+
localStorage.removeItem('migration_details');
221
+
}
222
+
}
223
+
224
+
// Save account details to localStorage with 30-minute expiry
225
+
const expiryTime = Date.now() + (30 * 60 * 1000); // 30 minutes in milliseconds
226
+
const migrationDetails = {
227
+
pds: pds,
228
+
inviteCode: inviteCode || null,
229
+
handle: accountDetails.handle + (pdsInfo?.availableUserDomains?.[0] ? `${pdsInfo.availableUserDomains[0]}` : ''),
230
+
email: accountDetails.email,
231
+
password: accountDetails.password,
232
+
expiryTime: expiryTime
233
+
};
234
+
localStorage.setItem('migration_details', JSON.stringify(migrationDetails));
235
+
236
+
// TODO: Implement migration
237
+
};
238
+
239
+
return (
240
+
<div className="actions-page">
241
+
<Header agent={agent} onLogout={onLogout} />
242
+
243
+
<div className="actions-container">
244
+
<div className="page-content">
245
+
<h2>Migrate your account</h2>
246
+
247
+
<div className={`form-section ${showAccountForm ? 'completed' : ''}`}>
248
+
<h3>Select your new PDS</h3>
249
+
<div className="form-group">
250
+
<label htmlFor="pds-input">Personal Data Server (PDS)</label>
251
+
<input
252
+
id="pds-input"
253
+
type="text"
254
+
className="form-input"
255
+
placeholder="Example: example-pds.com"
256
+
value={pds}
257
+
onChange={(e) => setPds(e.target.value)}
258
+
onBlur={handlePdsBlur}
259
+
disabled={isValidating || showAccountForm}
260
+
/>
261
+
{isValidating && (
262
+
<div className="loading-message">Checking PDS availability...</div>
263
+
)}
264
+
{error && (
265
+
<div className="error-message">{error}</div>
266
+
)}
267
+
{pdsInfo?.exists && !pdsInfo.requiresInvite && (
268
+
<div className="success-message">✓ This PDS does not require an invite code</div>
269
+
)}
270
+
</div>
271
+
272
+
{pdsInfo?.exists && pdsInfo.requiresInvite && (
273
+
<div className="form-group">
274
+
<label htmlFor="invite-code">Invite Code</label>
275
+
<input
276
+
id="invite-code"
277
+
type="text"
278
+
className="form-input"
279
+
placeholder="Example: bsky-noob-quest-abcde-12345"
280
+
value={inviteCode}
281
+
onChange={(e) => setInviteCode(e.target.value)}
282
+
disabled={showAccountForm}
283
+
/>
284
+
</div>
285
+
)}
286
+
287
+
{!showAccountForm && (
288
+
<div className="button-container">
289
+
<button
290
+
className="back-button"
291
+
onClick={() => navigate('/migration')}
292
+
>
293
+
← Go back
294
+
</button>
295
+
{pdsInfo?.exists && (!pdsInfo.requiresInvite || isInviteValid) && (
296
+
<button
297
+
className="continue-button"
298
+
onClick={handleContinue}
299
+
>
300
+
Continue →
301
+
</button>
302
+
)}
303
+
</div>
304
+
)}
305
+
</div>
306
+
307
+
{showAccountForm && (
308
+
<div className="form-section">
309
+
<h3>New account details</h3>
310
+
<div className="form-group">
311
+
<label htmlFor="handle-input">Handle</label>
312
+
<div className="handle-input-container">
313
+
<input
314
+
id="handle-input"
315
+
type="text"
316
+
className="form-input"
317
+
placeholder="username"
318
+
value={accountDetails.handle}
319
+
onChange={(e) => setAccountDetails(prev => ({ ...prev, handle: e.target.value }))}
320
+
/>
321
+
{pdsInfo?.availableUserDomains?.[0] && (
322
+
<span className="handle-domain">{pdsInfo.availableUserDomains[0]}</span>
323
+
)}
324
+
</div>
325
+
{isCustomHandle && !isCurrentHandleDefault() && (
326
+
<div className="info-message">
327
+
During the migration, you'll be assigned a temporary handle. After the migration is completed, we will assign your custom handle automatically.
328
+
</div>
329
+
)}
330
+
</div>
331
+
332
+
<div className="form-group">
333
+
<label htmlFor="email-input">Email</label>
334
+
<input
335
+
id="email-input"
336
+
type="email"
337
+
className="form-input"
338
+
placeholder="Your email address"
339
+
value={accountDetails.email}
340
+
onChange={(e) => setAccountDetails(prev => ({ ...prev, email: e.target.value }))}
341
+
/>
342
+
</div>
343
+
344
+
<div className="form-group">
345
+
<label htmlFor="password-input">Password</label>
346
+
<input
347
+
id="password-input"
348
+
type="password"
349
+
className="form-input"
350
+
placeholder="Your new password"
351
+
value={accountDetails.password}
352
+
onChange={(e) => setAccountDetails(prev => ({ ...prev, password: e.target.value }))}
353
+
/>
354
+
</div>
355
+
<small>We recommend using a different password for your new account. Save all of the above details somewhere before continuing.</small>
356
+
<div className="button-container">
357
+
<button
358
+
className="back-button"
359
+
onClick={() => setShowAccountForm(false)}
360
+
>
361
+
← Go back
362
+
</button>
363
+
<button
364
+
className="continue-button"
365
+
onClick={handleStartMigration}
366
+
>
367
+
Continue →
368
+
</button>
369
+
</div>
370
+
</div>
371
+
)}
372
+
</div>
373
+
</div>
374
+
<Footer />
375
+
</div>
376
+
);
377
+
}
+19
src/components/common/NetworkWarning.tsx
+19
src/components/common/NetworkWarning.tsx
···
1
+
import { useNetwork } from '../../contexts/NetworkContext';
2
+
import '../../styles/App.css';
3
+
4
+
export default function NetworkWarning() {
5
+
const { isOnline } = useNetwork();
6
+
7
+
if (isOnline) {
8
+
return null;
9
+
}
10
+
11
+
return (
12
+
<div className="network-warning">
13
+
<div className="network-warning-content">
14
+
<span className="network-warning-icon">⚠️</span>
15
+
<span>You are currently offline. Please check your internet connection.</span>
16
+
</div>
17
+
</div>
18
+
);
19
+
}
+14
-6
src/components/common/RecoveryKey.tsx
+14
-6
src/components/common/RecoveryKey.tsx
···
45
45
</ul>
46
46
</div>
47
47
48
-
<button
49
-
className="back-button"
50
-
onClick={() => navigate('/actions')}
51
-
>
52
-
← Go back
53
-
</button>
48
+
<div className="button-container">
49
+
<button
50
+
className="back-button"
51
+
onClick={() => navigate('/actions')}
52
+
>
53
+
← Go back
54
+
</button>
55
+
<button
56
+
className="continue-button"
57
+
onClick={() => navigate('/recovery-key/process')}
58
+
>
59
+
Continue →
60
+
</button>
61
+
</div>
54
62
</div>
55
63
</div>
56
64
<Footer />
+53
src/components/common/RecoveryKeyProcess.tsx
+53
src/components/common/RecoveryKeyProcess.tsx
···
1
+
import { useNavigate } from 'react-router-dom';
2
+
import { useState, useEffect } from 'react';
3
+
import { AtpAgent } from '@atproto/api';
4
+
import Footer from '../layout/Footer';
5
+
import Header from '../layout/Header';
6
+
import '../../styles/App.css';
7
+
8
+
interface RecoveryKeyProcessProps {
9
+
agent: AtpAgent;
10
+
onLogout: () => void;
11
+
}
12
+
13
+
export default function RecoveryKeyProcess({ agent, onLogout }: RecoveryKeyProcessProps) {
14
+
const navigate = useNavigate();
15
+
16
+
// Add warning when trying to close or navigate away
17
+
useEffect(() => {
18
+
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
19
+
e.preventDefault();
20
+
e.returnValue = '';
21
+
return '';
22
+
};
23
+
24
+
window.addEventListener('beforeunload', handleBeforeUnload);
25
+
26
+
return () => {
27
+
window.removeEventListener('beforeunload', handleBeforeUnload);
28
+
};
29
+
}, []);
30
+
31
+
return (
32
+
<div className="actions-page">
33
+
<Header agent={agent} onLogout={onLogout} />
34
+
35
+
<div className="actions-container">
36
+
<div className="page-content">
37
+
<h2>Add Recovery Key</h2>
38
+
<p>This page will guide you through the process of adding a recovery key to your account.</p>
39
+
40
+
<div className="button-container">
41
+
<button
42
+
className="back-button"
43
+
onClick={() => navigate('/recovery-key')}
44
+
>
45
+
← Go back
46
+
</button>
47
+
</div>
48
+
</div>
49
+
</div>
50
+
<Footer />
51
+
</div>
52
+
);
53
+
}
+1
-1
src/components/layout/Header.tsx
+1
-1
src/components/layout/Header.tsx
···
40
40
className="user-avatar"
41
41
/>
42
42
)}
43
-
<span className="user-handle">{agent.session?.handle}</span>
43
+
<span className="user-handle" title={agent.session?.handle}>{agent.session?.handle}</span>
44
44
<button className="logout-button" onClick={onLogout}>
45
45
Logout
46
46
</button>
+20
-4
src/contexts/AvatarContext.tsx
+20
-4
src/contexts/AvatarContext.tsx
···
1
-
import { createContext, useContext, useState, ReactNode } from 'react';
1
+
import { createContext, useContext, useState, ReactNode, useEffect } from 'react';
2
2
3
3
interface AvatarContextType {
4
4
avatarUrl: string;
···
10
10
export function AvatarProvider({ children }: { children: ReactNode }) {
11
11
const [avatarUrl, setAvatarUrl] = useState<string>('');
12
12
13
+
useEffect(() => {
14
+
const handleClearAvatar = () => {
15
+
setAvatarUrl('');
16
+
};
17
+
18
+
const contextElement = document.querySelector('[data-avatar-context]');
19
+
if (contextElement) {
20
+
contextElement.addEventListener('clearAvatar', handleClearAvatar);
21
+
return () => {
22
+
contextElement.removeEventListener('clearAvatar', handleClearAvatar);
23
+
};
24
+
}
25
+
}, []);
26
+
13
27
return (
14
-
<AvatarContext.Provider value={{ avatarUrl, setAvatarUrl }}>
15
-
{children}
16
-
</AvatarContext.Provider>
28
+
<div data-avatar-context>
29
+
<AvatarContext.Provider value={{ avatarUrl, setAvatarUrl }}>
30
+
{children}
31
+
</AvatarContext.Provider>
32
+
</div>
17
33
);
18
34
}
19
35
+38
src/contexts/NetworkContext.tsx
+38
src/contexts/NetworkContext.tsx
···
1
+
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
2
+
3
+
interface NetworkContextType {
4
+
isOnline: boolean;
5
+
}
6
+
7
+
const NetworkContext = createContext<NetworkContextType | undefined>(undefined);
8
+
9
+
export function NetworkProvider({ children }: { children: ReactNode }) {
10
+
const [isOnline, setIsOnline] = useState(navigator.onLine);
11
+
12
+
useEffect(() => {
13
+
const handleOnline = () => setIsOnline(true);
14
+
const handleOffline = () => setIsOnline(false);
15
+
16
+
window.addEventListener('online', handleOnline);
17
+
window.addEventListener('offline', handleOffline);
18
+
19
+
return () => {
20
+
window.removeEventListener('online', handleOnline);
21
+
window.removeEventListener('offline', handleOffline);
22
+
};
23
+
}, []);
24
+
25
+
return (
26
+
<NetworkContext.Provider value={{ isOnline }}>
27
+
{children}
28
+
</NetworkContext.Provider>
29
+
);
30
+
}
31
+
32
+
export function useNetwork() {
33
+
const context = useContext(NetworkContext);
34
+
if (context === undefined) {
35
+
throw new Error('useNetwork must be used within a NetworkProvider');
36
+
}
37
+
return context;
38
+
}
+158
src/styles/App.css
+158
src/styles/App.css
···
82
82
margin-bottom: 1rem;
83
83
}
84
84
85
+
.form-group label {
86
+
display: block;
87
+
margin-bottom: 0.5rem;
88
+
color: var(--text-color);
89
+
font-weight: 500;
90
+
}
91
+
92
+
.handle-input-container {
93
+
display: flex;
94
+
align-items: stretch;
95
+
gap: 0;
96
+
background-color: var(--input-bg);
97
+
border: 1px solid var(--border-color);
98
+
border-radius: 0.375rem;
99
+
}
100
+
101
+
.handle-input-container .form-input {
102
+
border: none;
103
+
padding-right: 0;
104
+
flex: 1;
105
+
border-radius: 0.375rem 0 0 0.375rem;
106
+
}
107
+
108
+
.handle-input-container .form-input:focus {
109
+
box-shadow: none;
110
+
}
111
+
112
+
.handle-domain {
113
+
color: var(--text-light);
114
+
font-size: 0.875rem;
115
+
white-space: nowrap;
116
+
background-color: var(--bg-color);
117
+
padding: 0 0.75rem;
118
+
border-radius: 0 0.375rem 0.375rem 0;
119
+
display: flex;
120
+
align-items: center;
121
+
}
122
+
85
123
.form-input {
86
124
width: 100%;
87
125
padding: 0.75rem 1rem;
···
108
146
font-size: 0.875rem;
109
147
text-align: center;
110
148
margin: 0.5rem 0;
149
+
}
150
+
151
+
.success-message {
152
+
color: #059669;
153
+
font-size: 0.875rem;
154
+
margin: 0.5rem 0;
155
+
display: flex;
156
+
align-items: center;
157
+
gap: 0.5rem;
111
158
}
112
159
113
160
.submit-button {
···
504
551
gap: 0.5rem;
505
552
}
506
553
554
+
.button-container {
555
+
display: flex;
556
+
justify-content: space-between;
557
+
gap: 1rem;
558
+
margin-top: 2rem;
559
+
}
560
+
561
+
.back-button {
562
+
background-color: var(--text-light);
563
+
color: var(--white);
564
+
border: none;
565
+
padding: 0.75rem 1.5rem;
566
+
border-radius: 0.375rem;
567
+
font-size: 0.875rem;
568
+
font-weight: 500;
569
+
cursor: pointer;
570
+
transition: background-color 0.2s;
571
+
display: inline-flex;
572
+
align-items: center;
573
+
gap: 0.5rem;
574
+
}
575
+
507
576
.back-button:hover {
577
+
background-color: #c7c7c7;
578
+
}
579
+
580
+
.continue-button {
581
+
background-color: var(--primary-color);
582
+
color: var(--white);
583
+
border: none;
584
+
padding: 0.75rem 1.5rem;
585
+
border-radius: 0.375rem;
586
+
font-size: 0.875rem;
587
+
font-weight: 500;
588
+
cursor: pointer;
589
+
transition: background-color 0.2s;
590
+
display: inline-flex;
591
+
align-items: center;
592
+
gap: 0.5rem;
593
+
margin-left: auto;
594
+
}
595
+
596
+
.continue-button:hover {
508
597
background-color: var(--primary-hover);
509
598
}
510
599
···
577
666
578
667
.docs-section a:hover::after {
579
668
transform: translateX(4px);
669
+
}
670
+
671
+
.network-warning {
672
+
position: fixed;
673
+
top: 0;
674
+
left: 0;
675
+
right: 0;
676
+
bottom: 0;
677
+
background-color: rgba(0, 0, 0, 0.8);
678
+
z-index: 1000;
679
+
display: flex;
680
+
align-items: center;
681
+
justify-content: center;
682
+
}
683
+
684
+
.network-warning-content {
685
+
max-width: 800px;
686
+
padding: 2rem;
687
+
display: flex;
688
+
align-items: center;
689
+
gap: 0.75rem;
690
+
color: #92400e;
691
+
font-size: 1.125rem;
692
+
text-align: center;
693
+
background-color: rgba(255, 255, 255, 0.9);
694
+
border-radius: 0.5rem;
695
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
696
+
}
697
+
698
+
.network-warning-icon {
699
+
font-size: 1.5rem;
700
+
flex-shrink: 0;
701
+
}
702
+
703
+
.form-section {
704
+
background: var(--white);
705
+
border-radius: 8px;
706
+
padding: 2rem;
707
+
margin-bottom: 2rem;
708
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
709
+
transition: all 0.3s ease;
710
+
border: 1px solid var(--border-color);
711
+
}
712
+
713
+
.form-section.completed {
714
+
opacity: 0.6;
715
+
pointer-events: none;
716
+
background: var(--bg-color);
717
+
}
718
+
719
+
.form-section h3 {
720
+
margin-top: 0;
721
+
margin-bottom: 1.5rem;
722
+
color: var(--text-color);
723
+
font-size: 1.25rem;
724
+
}
725
+
726
+
.info-message {
727
+
color: var(--primary-color);
728
+
font-size: 0.875rem;
729
+
margin-top: 0.5rem;
730
+
display: flex;
731
+
align-items: center;
732
+
gap: 0.5rem;
733
+
}
734
+
735
+
.info-message::before {
736
+
content: "ℹ️";
737
+
font-size: 1rem;
580
738
}