ai-generated junk tool for migrating atproto identities in-browser

[migration] create initial process pages, add pds selection and new account creation (non-functional) to migration process

+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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/Footer.tsx
··· 5 5 <footer className="footer"> 6 6 Made by{' '} 7 7 <a 8 - href="https://bsky.app/profile/noob.quest" 8 + href="https://bsky.app/profile/did:plc:5szlrh3xkfxxsuu4mo6oe6h7" 9 9 target="_blank" 10 10 rel="noopener noreferrer" 11 11 className="footer-link"
+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
··· 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
··· 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
··· 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 }