Our Personal Data Server from scratch!
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 515 lines 14 kB view raw
1<script lang="ts"> 2 import { setSession } from '../lib/auth.svelte' 3 import { navigate, routes } from '../lib/router.svelte' 4 import { _ } from '../lib/i18n' 5 import { api } from '../lib/api' 6 import { startOAuthLogin } from '../lib/oauth' 7 import { unsafeAsAccessToken } from '../lib/types/branded' 8 import { 9 createInboundMigrationFlow, 10 createOfflineInboundMigrationFlow, 11 hasPendingMigration, 12 hasPendingOfflineMigration, 13 getResumeInfo, 14 getOfflineResumeInfo, 15 clearMigrationState, 16 clearOfflineState, 17 loadMigrationState, 18 } from '../lib/migration' 19 import InboundWizard from '../components/migration/InboundWizard.svelte' 20 import OfflineInboundWizard from '../components/migration/OfflineInboundWizard.svelte' 21 22 type Direction = 'select' | 'inbound' | 'offline-inbound' 23 let direction = $state<Direction>('select') 24 let showResumeModal = $state(false) 25 let resumeInfo = $state<ReturnType<typeof getResumeInfo>>(null) 26 let oauthError = $state<string | null>(null) 27 let oauthLoading = $state(false) 28 29 let inboundFlow = $state<ReturnType<typeof createInboundMigrationFlow> | null>(null) 30 let offlineFlow = $state<ReturnType<typeof createOfflineInboundMigrationFlow> | null>(null) 31 let oauthCallbackProcessed = $state(false) 32 33 $effect(() => { 34 if (oauthCallbackProcessed) return 35 36 const url = new URL(window.location.href) 37 const code = url.searchParams.get('code') 38 const state = url.searchParams.get('state') 39 const errorParam = url.searchParams.get('error') 40 const errorDescription = url.searchParams.get('error_description') 41 42 if (errorParam) { 43 oauthCallbackProcessed = true 44 oauthError = errorDescription || errorParam 45 window.history.replaceState({}, '', '/app/migrate') 46 return 47 } 48 49 if (code && state) { 50 oauthCallbackProcessed = true 51 window.history.replaceState({}, '', '/app/migrate') 52 direction = 'inbound' 53 oauthLoading = true 54 inboundFlow = createInboundMigrationFlow() 55 56 const stored = loadMigrationState() 57 if (stored && stored.direction === 'inbound') { 58 inboundFlow.resumeFromState(stored) 59 } 60 61 inboundFlow.handleOAuthCallback(code, state) 62 .then(() => { 63 oauthLoading = false 64 }) 65 .catch((e) => { 66 oauthLoading = false 67 oauthError = e.message || 'OAuth authentication failed' 68 inboundFlow = null 69 direction = 'select' 70 }) 71 return 72 } 73 }) 74 75 const urlParams = new URLSearchParams(window.location.search) 76 const hasOAuthCallback = urlParams.has('code') || urlParams.has('error') 77 78 if (!hasOAuthCallback) { 79 if (hasPendingMigration()) { 80 const info = getResumeInfo() 81 if (info) { 82 if (info.step === 'success') { 83 clearMigrationState() 84 } else { 85 resumeInfo = info 86 const stored = loadMigrationState() 87 if (stored && stored.direction === 'inbound') { 88 direction = 'inbound' 89 const flow = createInboundMigrationFlow() 90 flow.resumeFromState(stored) 91 inboundFlow = flow 92 } 93 } 94 } 95 } else if (hasPendingOfflineMigration()) { 96 const offlineInfo = getOfflineResumeInfo() 97 if (offlineInfo && offlineInfo.step === 'success') { 98 clearOfflineState() 99 } else { 100 direction = 'offline-inbound' 101 const flow = createOfflineInboundMigrationFlow() 102 flow.tryResume() 103 offlineFlow = flow 104 } 105 } 106 } 107 108 function selectInbound() { 109 direction = 'inbound' 110 inboundFlow = createInboundMigrationFlow() 111 } 112 113 function selectOfflineInbound() { 114 direction = 'offline-inbound' 115 offlineFlow = createOfflineInboundMigrationFlow() 116 } 117 118 function handleResume() { 119 const stored = loadMigrationState() 120 if (!stored) return 121 122 showResumeModal = false 123 124 if (stored.direction === 'inbound') { 125 direction = 'inbound' 126 inboundFlow = createInboundMigrationFlow() 127 inboundFlow.resumeFromState(stored) 128 } 129 } 130 131 function handleStartOver() { 132 showResumeModal = false 133 clearMigrationState() 134 resumeInfo = null 135 } 136 137 function handleBack() { 138 if (inboundFlow) { 139 inboundFlow.reset() 140 inboundFlow = null 141 } 142 if (offlineFlow) { 143 offlineFlow.reset() 144 offlineFlow = null 145 } 146 direction = 'select' 147 } 148 149 async function handleInboundComplete() { 150 const session = inboundFlow?.getLocalSession() 151 if (session) { 152 try { 153 await api.establishOAuthSession(unsafeAsAccessToken(session.accessJwt)) 154 clearMigrationState() 155 await startOAuthLogin(session.handle) 156 } catch (e) { 157 console.error('Failed to establish OAuth session, falling back to direct login:', e) 158 setSession({ 159 did: session.did, 160 handle: session.handle, 161 accessJwt: session.accessJwt, 162 refreshJwt: '', 163 }) 164 navigate(routes.dashboard) 165 } 166 } else { 167 navigate(routes.dashboard) 168 } 169 } 170 171 async function handleOfflineComplete() { 172 const session = offlineFlow?.getLocalSession() 173 if (session) { 174 try { 175 await api.establishOAuthSession(unsafeAsAccessToken(session.accessJwt)) 176 clearOfflineState() 177 await startOAuthLogin(session.handle) 178 } catch (e) { 179 console.error('Failed to establish OAuth session, falling back to direct login:', e) 180 setSession({ 181 did: session.did, 182 handle: session.handle, 183 accessJwt: session.accessJwt, 184 refreshJwt: '', 185 }) 186 navigate(routes.dashboard) 187 } 188 } else { 189 navigate(routes.dashboard) 190 } 191 } 192</script> 193 194<div class="migration-page"> 195 {#if showResumeModal && resumeInfo} 196 <div class="modal-overlay"> 197 <div class="modal"> 198 <h2>{$_('migration.resume.title')}</h2> 199 <p>{$_('migration.resume.incomplete')}</p> 200 <div class="resume-details"> 201 <div class="detail-row"> 202 <span class="label">{$_('migration.resume.direction')}:</span> 203 <span class="value">{$_('migration.resume.migratingHere')}</span> 204 </div> 205 {#if resumeInfo.sourceHandle} 206 <div class="detail-row"> 207 <span class="label">{$_('migration.resume.from')}:</span> 208 <span class="value">{resumeInfo.sourceHandle}</span> 209 </div> 210 {/if} 211 {#if resumeInfo.targetHandle} 212 <div class="detail-row"> 213 <span class="label">{$_('migration.resume.to')}:</span> 214 <span class="value">{resumeInfo.targetHandle}</span> 215 </div> 216 {/if} 217 <div class="detail-row"> 218 <span class="label">{$_('migration.resume.progress')}:</span> 219 <span class="value">{resumeInfo.progressSummary}</span> 220 </div> 221 </div> 222 <p class="note">{$_('migration.resume.reenterCredentials')}</p> 223 <div class="modal-actions"> 224 <button class="ghost" onclick={handleStartOver}>{$_('migration.resume.startOver')}</button> 225 <button onclick={handleResume}>{$_('migration.resume.resumeButton')}</button> 226 </div> 227 </div> 228 </div> 229 {/if} 230 231 {#if oauthLoading} 232 <div class="loading"> 233 <div class="spinner md"></div> 234 <p>{$_('migration.oauthCompleting')}</p> 235 </div> 236 {:else if oauthError} 237 <div class="oauth-error"> 238 <h2>{$_('migration.oauthFailed')}</h2> 239 <p>{oauthError}</p> 240 <button onclick={() => { oauthError = null; direction = 'select' }}>{$_('migration.tryAgain')}</button> 241 </div> 242 {:else if direction === 'select'} 243 <header class="page-header"> 244 <h1>{$_('migration.title')}</h1> 245 <p class="subtitle">{$_('migration.subtitle')}</p> 246 </header> 247 248 <div class="direction-cards"> 249 <button class="direction-card ghost" onclick={selectInbound}> 250 <h2>{$_('migration.migrateHere')}</h2> 251 <p>{$_('migration.migrateHereDesc')}</p> 252 <ul class="features"> 253 <li>{$_('migration.bringDid')}</li> 254 <li>{$_('migration.transferData')}</li> 255 <li>{$_('migration.keepFollowers')}</li> 256 </ul> 257 </button> 258 259 <button class="direction-card ghost offline-card" onclick={selectOfflineInbound}> 260 <h2>{$_('migration.offlineRestore')}</h2> 261 <p>{$_('migration.offlineRestoreDesc')}</p> 262 <ul class="features"> 263 <li>{$_('migration.offlineFeature1')}</li> 264 <li>{$_('migration.offlineFeature2')}</li> 265 <li>{$_('migration.offlineFeature3')}</li> 266 </ul> 267 </button> 268 </div> 269 270 <div class="info-section"> 271 <h3>{$_('migration.whatIsMigration')}</h3> 272 <p>{$_('migration.whatIsMigrationDesc')}</p> 273 274 <h3>{$_('migration.beforeMigrate')}</h3> 275 <ul> 276 <li>{$_('migration.beforeMigrate1')}</li> 277 <li>{$_('migration.beforeMigrate2')}</li> 278 <li>{$_('migration.beforeMigrate3')}</li> 279 <li>{$_('migration.beforeMigrate4')}</li> 280 </ul> 281 282 <div class="warning-box"> 283 <strong>Important:</strong> {$_('migration.importantWarning')} 284 <a href="https://github.com/bluesky-social/pds/blob/main/ACCOUNT_MIGRATION.md" target="_blank" rel="noopener"> 285 {$_('migration.learnMore')} 286 </a> 287 </div> 288 </div> 289 290 {:else if direction === 'inbound' && inboundFlow} 291 <InboundWizard 292 flow={inboundFlow} 293 {resumeInfo} 294 onBack={handleBack} 295 onComplete={handleInboundComplete} 296 /> 297 298 {:else if direction === 'offline-inbound' && offlineFlow} 299 <OfflineInboundWizard 300 flow={offlineFlow} 301 onBack={handleBack} 302 onComplete={handleOfflineComplete} 303 /> 304 {/if} 305</div> 306 307<style> 308 .migration-page { 309 max-width: var(--width-lg); 310 margin: var(--space-9) auto; 311 padding: var(--space-7); 312 } 313 314 .page-header { 315 text-align: center; 316 margin-bottom: var(--space-8); 317 } 318 319 .page-header h1 { 320 margin: 0 0 var(--space-3) 0; 321 } 322 323 .subtitle { 324 color: var(--text-secondary); 325 margin: 0; 326 font-size: var(--text-lg); 327 } 328 329 .direction-cards { 330 display: grid; 331 grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); 332 gap: var(--space-6); 333 margin-bottom: var(--space-8); 334 } 335 336 .direction-card { 337 display: flex; 338 flex-direction: column; 339 align-items: stretch; 340 background: var(--bg-secondary); 341 border: 1px solid var(--border-color); 342 border-radius: var(--radius-xl); 343 padding: var(--space-6); 344 text-align: left; 345 cursor: pointer; 346 transition: all 0.2s ease; 347 } 348 349 .direction-card:hover:not(:disabled) { 350 border-color: var(--accent); 351 transform: translateY(-2px); 352 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); 353 } 354 355 .direction-card:disabled { 356 opacity: 0.6; 357 cursor: not-allowed; 358 } 359 360 .direction-card h2 { 361 margin: 0 0 var(--space-3) 0; 362 font-size: var(--text-xl); 363 color: var(--text-primary); 364 } 365 366 .direction-card p { 367 color: var(--text-secondary); 368 margin: 0 0 var(--space-4) 0; 369 font-size: var(--text-sm); 370 } 371 372 .features { 373 margin: 0; 374 padding-left: var(--space-5); 375 color: var(--text-secondary); 376 font-size: var(--text-sm); 377 } 378 379 .features li { 380 margin-bottom: var(--space-2); 381 } 382 383 .info-section { 384 background: var(--bg-secondary); 385 border-radius: var(--radius-xl); 386 padding: var(--space-6); 387 } 388 389 .info-section h3 { 390 margin: 0 0 var(--space-3) 0; 391 font-size: var(--text-lg); 392 } 393 394 .info-section h3:not(:first-child) { 395 margin-top: var(--space-6); 396 } 397 398 .info-section p { 399 color: var(--text-secondary); 400 line-height: var(--leading-relaxed); 401 margin: 0; 402 } 403 404 .info-section ul { 405 color: var(--text-secondary); 406 padding-left: var(--space-5); 407 margin: var(--space-3) 0 0 0; 408 } 409 410 .info-section li { 411 margin-bottom: var(--space-2); 412 } 413 414 .warning-box { 415 margin-top: var(--space-6); 416 padding: var(--space-5); 417 background: var(--warning-bg); 418 border: 1px solid var(--warning-border); 419 border-radius: var(--radius-lg); 420 font-size: var(--text-sm); 421 } 422 423 .warning-box strong { 424 color: var(--warning-text); 425 } 426 427 .warning-box a { 428 display: inline; 429 margin-top: var(--space-2); 430 } 431 432 .modal-overlay { 433 position: fixed; 434 inset: 0; 435 background: var(--overlay-bg); 436 display: flex; 437 align-items: center; 438 justify-content: center; 439 z-index: var(--z-modal); 440 } 441 442 .modal { 443 background: var(--bg-primary); 444 border-radius: var(--radius-xl); 445 padding: var(--space-6); 446 max-width: var(--width-sm); 447 width: 90%; 448 } 449 450 .modal h2 { 451 margin: 0 0 var(--space-4) 0; 452 } 453 454 .modal p { 455 color: var(--text-secondary); 456 margin: 0 0 var(--space-4) 0; 457 } 458 459 .resume-details { 460 background: var(--bg-secondary); 461 border-radius: var(--radius-lg); 462 padding: var(--space-4); 463 margin-bottom: var(--space-4); 464 } 465 466 .detail-row { 467 display: flex; 468 justify-content: space-between; 469 padding: var(--space-2) 0; 470 font-size: var(--text-sm); 471 } 472 473 .detail-row:not(:last-child) { 474 border-bottom: 1px solid var(--border-color); 475 } 476 477 .detail-row .label { 478 color: var(--text-secondary); 479 } 480 481 .detail-row .value { 482 font-weight: var(--font-medium); 483 } 484 485 .note { 486 font-size: var(--text-sm); 487 font-style: italic; 488 } 489 490 .modal-actions { 491 display: flex; 492 gap: var(--space-3); 493 justify-content: flex-end; 494 } 495 496 .oauth-error { 497 max-width: 500px; 498 margin: 0 auto; 499 text-align: center; 500 padding: var(--space-8); 501 background: var(--error-bg); 502 border: 1px solid var(--error-border); 503 border-radius: var(--radius-xl); 504 } 505 506 .oauth-error h2 { 507 margin: 0 0 var(--space-4) 0; 508 color: var(--error-text); 509 } 510 511 .oauth-error p { 512 color: var(--text-secondary); 513 margin: 0 0 var(--space-5) 0; 514 } 515</style>