this repo has no description
1<script lang="ts"> 2 import { onMount } from 'svelte' 3 import { _ } from '../lib/i18n' 4 import { getAuthState } from '../lib/auth.svelte' 5 import { getServerConfigState } from '../lib/serverConfig.svelte' 6 import { api } from '../lib/api' 7 8 const auth = getAuthState() 9 const serverConfig = getServerConfigState() 10 const sourceUrl = 'https://tangled.org/lewis.moe/bspds-sandbox' 11 12 let pdsHostname = $state<string | null>(null) 13 let pdsVersion = $state<string | null>(null) 14 let userCount = $state<number | null>(null) 15 16 const heroWords = ['Bluesky', 'Tangled', 'Leaflet', 'ATProto'] 17 const wordSpacing: Record<string, string> = { 18 'Bluesky': '0.01em', 19 'Tangled': '0.02em', 20 'Leaflet': '0.05em', 21 'ATProto': '0', 22 } 23 let currentWordIndex = $state(0) 24 let isTransitioning = $state(false) 25 let currentWord = $derived(heroWords[currentWordIndex]) 26 let currentSpacing = $derived(wordSpacing[currentWord] || '0') 27 28 onMount(() => { 29 api.describeServer().then(info => { 30 if (info.availableUserDomains?.length) { 31 pdsHostname = info.availableUserDomains[0] 32 } 33 if (info.version) { 34 pdsVersion = info.version 35 } 36 }).catch(() => {}) 37 38 const baseDuration = 2000 39 let wordTimeout: ReturnType<typeof setTimeout> 40 41 function cycleWord() { 42 isTransitioning = true 43 setTimeout(() => { 44 currentWordIndex = (currentWordIndex + 1) % heroWords.length 45 isTransitioning = false 46 const duration = heroWords[currentWordIndex] === 'ATProto' ? baseDuration * 2 : baseDuration 47 wordTimeout = setTimeout(cycleWord, duration) 48 }, 100) 49 } 50 51 wordTimeout = setTimeout(cycleWord, baseDuration) 52 53 api.listRepos(1000).then(data => { 54 userCount = data.repos.length 55 }).catch(() => {}) 56 57 const pattern = document.getElementById('dotPattern') 58 if (!pattern) return 59 60 const spacing = 32 61 const cols = Math.ceil((window.innerWidth + 600) / spacing) 62 const rows = Math.ceil((window.innerHeight + 100) / spacing) 63 const dots: { el: HTMLElement; x: number; y: number }[] = [] 64 65 for (let y = 0; y < rows; y++) { 66 for (let x = 0; x < cols; x++) { 67 const dot = document.createElement('div') 68 dot.className = 'dot' 69 dot.style.left = (x * spacing) + 'px' 70 dot.style.top = (y * spacing) + 'px' 71 pattern.appendChild(dot) 72 dots.push({ el: dot, x: x * spacing, y: y * spacing }) 73 } 74 } 75 76 let mouseX = -1000 77 let mouseY = -1000 78 79 const handleMouseMove = (e: MouseEvent) => { 80 mouseX = e.clientX 81 mouseY = e.clientY 82 } 83 84 document.addEventListener('mousemove', handleMouseMove) 85 86 let animationId: number 87 88 function updateDots() { 89 const patternRect = pattern.getBoundingClientRect() 90 dots.forEach(dot => { 91 const dotX = patternRect.left + dot.x + 5 92 const dotY = patternRect.top + dot.y + 5 93 const dist = Math.hypot(mouseX - dotX, mouseY - dotY) 94 const maxDist = 120 95 const scale = Math.min(1, Math.max(0.1, dist / maxDist)) 96 dot.el.style.transform = `scale(${scale})` 97 }) 98 animationId = requestAnimationFrame(updateDots) 99 } 100 updateDots() 101 102 return () => { 103 document.removeEventListener('mousemove', handleMouseMove) 104 cancelAnimationFrame(animationId) 105 clearTimeout(wordTimeout) 106 } 107 }) 108</script> 109 110<div class="pattern-container"> 111 <div class="pattern" id="dotPattern"></div> 112</div> 113<div class="pattern-fade"></div> 114 115<nav> 116 <div class="nav-left"> 117 {#if serverConfig.hasLogo} 118 <img src="/logo" alt="Logo" class="nav-logo" /> 119 {/if} 120 {#if pdsHostname} 121 <span class="hostname">{pdsHostname}</span> 122 {#if userCount !== null} 123 <span class="user-count">{userCount} {userCount === 1 ? 'user' : 'users'}</span> 124 {/if} 125 {:else} 126 <span class="hostname placeholder">loading...</span> 127 {/if} 128 </div> 129 <span class="nav-meta">{pdsVersion || ''}</span> 130</nav> 131 132<div class="home"> 133 <section class="hero"> 134 <h1>A home for your <span class="cycling-word-container"><span class="cycling-word" class:transitioning={isTransitioning} style="letter-spacing: {currentSpacing}">{currentWord}</span></span> account</h1> 135 136 <p class="lede">Tranquil PDS is a Personal Data Server, the thing that stores your posts, profile, and keys. Bluesky runs one for you, but you can run your own.</p> 137 138 <div class="actions"> 139 {#if auth.session} 140 <a href="#/dashboard" class="btn primary">@{auth.session.handle}</a> 141 {:else} 142 <a href="#/register" class="btn primary">Join This Server</a> 143 <a href={sourceUrl} class="btn secondary" target="_blank" rel="noopener">Run Your Own</a> 144 {/if} 145 </div> 146 147 <blockquote> 148 <p>"Nature does not hurry, yet everything is accomplished."</p> 149 <cite>Lao Tzu</cite> 150 </blockquote> 151 </section> 152 153 <section class="content"> 154 <h2>What you get</h2> 155 156 <div class="features"> 157 <div class="feature"> 158 <h3>Real security</h3> 159 <p>Sign in with passkeys, add two-factor authentication, set up backup codes, and mark devices you trust. Your account stays yours.</p> 160 </div> 161 162 <div class="feature"> 163 <h3>Your own identity</h3> 164 <p>Use your own domain as your handle, or get a subdomain on ours. Either way, your identity moves with you if you ever leave.</p> 165 </div> 166 167 <div class="feature"> 168 <h3>Stay in the loop</h3> 169 <p>Get important alerts where you actually see them: email, Discord, Telegram, or Signal.</p> 170 </div> 171 172 <div class="feature"> 173 <h3>You decide what apps can do</h3> 174 <p>When an app asks for access, you'll see exactly what it wants in plain language. Grant what makes sense, deny what doesn't.</p> 175 </div> 176 177 <div class="feature"> 178 <h3>App passwords with guardrails</h3> 179 <p>Create app passwords that can only do specific things: read-only for feed readers, post-only for bots. Full control over what each password can access.</p> 180 </div> 181 182 <div class="feature"> 183 <h3>Delegate without sharing passwords</h3> 184 <p>Let team members or tools manage your account with specific permission levels. They authenticate with their own credentials, you see everything they do in an audit log.</p> 185 </div> 186 187 <div class="feature"> 188 <h3>Automatic backups</h3> 189 <p>Your repository is backed up daily to object storage. Download any backup or restore with one click. You own your data, even if the worst happens.</p> 190 </div> 191 </div> 192 193 <h2>Everything in one place</h2> 194 195 <p>Manage your profile, security settings, connected apps, and more from a clean dashboard. No command line or 3rd party apps required.</p> 196 197 <h2>Works with everything</h2> 198 199 <p>Use any ATProto app you already like. Tranquil PDS speaks the same language as Bluesky's servers, so all your favorite clients and tools just work.</p> 200 201 <h2>Ready to try it?</h2> 202 203 <p>Join this server, or grab the source and run your own. Either way, you can migrate an existing account over and your followers, posts, and identity come with you.</p> 204 205 <div class="actions"> 206 {#if auth.session} 207 <a href="#/dashboard" class="btn primary">@{auth.session.handle}</a> 208 {:else} 209 <a href="#/register" class="btn primary">Join This Server</a> 210 <a href={sourceUrl} class="btn secondary" target="_blank" rel="noopener">View Source</a> 211 {/if} 212 </div> 213 </section> 214 215 <footer class="site-footer"> 216 <span>Made by people who don't take themselves too seriously</span> 217 <span>Open Source: issues & PRs welcome</span> 218 </footer> 219</div> 220 221<style> 222 .pattern-container { 223 position: fixed; 224 top: -32px; 225 left: -32px; 226 right: -32px; 227 bottom: -32px; 228 pointer-events: none; 229 z-index: 1; 230 overflow: hidden; 231 } 232 233 .pattern { 234 position: absolute; 235 top: 0; 236 left: 0; 237 width: calc(100% + 500px); 238 height: 100%; 239 animation: drift 80s linear infinite; 240 } 241 242 .pattern :global(.dot) { 243 position: absolute; 244 width: 10px; 245 height: 10px; 246 background: rgba(0, 0, 0, 0.06); 247 border-radius: 50%; 248 transition: transform 0.04s linear; 249 } 250 251 @media (prefers-color-scheme: dark) { 252 .pattern :global(.dot) { 253 background: rgba(255, 255, 255, 0.1); 254 } 255 } 256 257 .pattern-fade { 258 position: fixed; 259 top: 0; 260 left: 0; 261 right: 0; 262 bottom: 0; 263 background: linear-gradient(135deg, transparent 50%, var(--bg-primary) 75%); 264 pointer-events: none; 265 z-index: 2; 266 } 267 268 @keyframes drift { 269 0% { transform: translateX(-500px); } 270 100% { transform: translateX(0); } 271 } 272 273 nav { 274 position: fixed; 275 top: 12px; 276 left: 32px; 277 right: 32px; 278 background: var(--accent); 279 padding: 10px 18px; 280 z-index: 100; 281 border-radius: var(--radius-xl); 282 display: flex; 283 justify-content: space-between; 284 align-items: center; 285 } 286 287 .nav-left { 288 display: flex; 289 align-items: center; 290 gap: var(--space-3); 291 } 292 293 .nav-logo { 294 height: 28px; 295 width: auto; 296 object-fit: contain; 297 border-radius: var(--radius-sm); 298 } 299 300 .hostname { 301 font-weight: var(--font-semibold); 302 font-size: var(--text-base); 303 letter-spacing: 0.08em; 304 color: var(--text-inverse); 305 text-transform: uppercase; 306 } 307 308 .hostname.placeholder { 309 opacity: 0.4; 310 } 311 312 .user-count { 313 font-size: var(--text-sm); 314 color: var(--text-inverse); 315 opacity: 0.85; 316 padding: 4px 10px; 317 background: rgba(255, 255, 255, 0.15); 318 border-radius: var(--radius-md); 319 white-space: nowrap; 320 } 321 322 @media (prefers-color-scheme: dark) { 323 .user-count { 324 background: rgba(0, 0, 0, 0.15); 325 } 326 } 327 328 .nav-meta { 329 font-size: var(--text-sm); 330 color: var(--text-inverse); 331 opacity: 0.6; 332 letter-spacing: 0.05em; 333 } 334 335 .home { 336 position: relative; 337 z-index: 10; 338 max-width: var(--width-xl); 339 margin: 0 auto; 340 padding: 72px 32px 32px; 341 } 342 343 .hero { 344 padding: var(--space-7) 0 var(--space-8); 345 border-bottom: 1px solid var(--border-color); 346 margin-bottom: var(--space-8); 347 } 348 349 h1 { 350 font-size: var(--text-4xl); 351 font-weight: var(--font-semibold); 352 line-height: var(--leading-tight); 353 margin-bottom: var(--space-6); 354 letter-spacing: -0.02em; 355 } 356 357 .cycling-word-container { 358 display: inline-block; 359 width: 3.9em; 360 text-align: left; 361 } 362 363 .cycling-word { 364 display: inline-block; 365 transition: opacity 0.1s ease, transform 0.1s ease; 366 } 367 368 .cycling-word.transitioning { 369 opacity: 0; 370 transform: scale(0.95); 371 } 372 373 .lede { 374 font-size: var(--text-xl); 375 font-weight: var(--font-medium); 376 color: var(--text-primary); 377 line-height: var(--leading-relaxed); 378 margin-bottom: 0; 379 } 380 381 .actions { 382 display: flex; 383 gap: var(--space-4); 384 margin-top: var(--space-7); 385 } 386 387 .btn { 388 font-size: var(--text-sm); 389 font-weight: var(--font-medium); 390 text-transform: uppercase; 391 letter-spacing: 0.06em; 392 padding: var(--space-4) var(--space-6); 393 border-radius: var(--radius-lg); 394 text-decoration: none; 395 transition: all var(--transition-normal); 396 border: 1px solid transparent; 397 } 398 399 .btn.primary { 400 background: var(--secondary); 401 color: var(--text-inverse); 402 border-color: var(--secondary); 403 } 404 405 .btn.primary:hover { 406 background: var(--secondary-hover); 407 border-color: var(--secondary-hover); 408 } 409 410 .btn.secondary { 411 background: transparent; 412 color: var(--text-primary); 413 border-color: var(--border-color); 414 } 415 416 .btn.secondary:hover { 417 background: var(--secondary-muted); 418 border-color: var(--secondary); 419 color: var(--secondary); 420 } 421 422 blockquote { 423 margin: var(--space-8) 0 0 0; 424 padding: var(--space-6); 425 background: var(--accent-muted); 426 border-left: 3px solid var(--accent); 427 border-radius: 0 var(--radius-xl) var(--radius-xl) 0; 428 } 429 430 blockquote p { 431 font-size: var(--text-lg); 432 color: var(--text-primary); 433 font-style: italic; 434 margin-bottom: var(--space-3); 435 } 436 437 blockquote cite { 438 font-size: var(--text-sm); 439 color: var(--text-secondary); 440 font-style: normal; 441 text-transform: uppercase; 442 letter-spacing: 0.05em; 443 } 444 445 .content h2 { 446 font-size: var(--text-sm); 447 font-weight: var(--font-bold); 448 text-transform: uppercase; 449 letter-spacing: 0.1em; 450 color: var(--accent-light); 451 margin: var(--space-8) 0 var(--space-5); 452 } 453 454 .content h2:first-child { 455 margin-top: 0; 456 } 457 458 .content > p { 459 font-size: var(--text-base); 460 color: var(--text-secondary); 461 margin-bottom: var(--space-5); 462 line-height: var(--leading-relaxed); 463 } 464 465 .features { 466 display: grid; 467 grid-template-columns: repeat(2, 1fr); 468 gap: var(--space-6); 469 margin: var(--space-6) 0 var(--space-8); 470 } 471 472 .feature { 473 padding: var(--space-5); 474 background: var(--bg-secondary); 475 border-radius: var(--radius-xl); 476 border: 1px solid var(--border-color); 477 } 478 479 .feature h3 { 480 font-size: var(--text-base); 481 font-weight: var(--font-semibold); 482 color: var(--text-primary); 483 margin-bottom: var(--space-3); 484 } 485 486 .feature p { 487 font-size: var(--text-sm); 488 color: var(--text-secondary); 489 margin: 0; 490 line-height: var(--leading-relaxed); 491 } 492 493 @media (max-width: 700px) { 494 .features { 495 grid-template-columns: 1fr; 496 } 497 498 h1 { 499 font-size: var(--text-3xl); 500 } 501 502 .actions { 503 flex-direction: column; 504 } 505 506 .btn { 507 text-align: center; 508 } 509 510 .user-count, 511 .nav-meta { 512 display: none; 513 } 514 } 515 516 .site-footer { 517 margin-top: var(--space-9); 518 padding-top: var(--space-7); 519 display: flex; 520 justify-content: space-between; 521 font-size: var(--text-sm); 522 color: var(--text-muted); 523 text-transform: uppercase; 524 letter-spacing: 0.05em; 525 border-top: 1px solid var(--border-color); 526 } 527</style>