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 </div> 187 188 <h2>Everything in one place</h2> 189 190 <p>Manage your profile, security settings, connected apps, and more from a clean dashboard. No command line or 3rd party apps required.</p> 191 192 <h2>Works with everything</h2> 193 194 <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> 195 196 <h2>Ready to try it?</h2> 197 198 <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> 199 200 <div class="actions"> 201 {#if auth.session} 202 <a href="#/dashboard" class="btn primary">@{auth.session.handle}</a> 203 {:else} 204 <a href="#/register" class="btn primary">Join This Server</a> 205 <a href={sourceUrl} class="btn secondary" target="_blank" rel="noopener">View Source</a> 206 {/if} 207 </div> 208 </section> 209 210 <footer class="site-footer"> 211 <span>Made by people who don't take themselves too seriously</span> 212 <span>Open Source: issues & PRs welcome</span> 213 </footer> 214</div> 215 216<style> 217 .pattern-container { 218 position: fixed; 219 top: -32px; 220 left: -32px; 221 right: -32px; 222 bottom: -32px; 223 pointer-events: none; 224 z-index: 1; 225 overflow: hidden; 226 } 227 228 .pattern { 229 position: absolute; 230 top: 0; 231 left: 0; 232 width: calc(100% + 500px); 233 height: 100%; 234 animation: drift 80s linear infinite; 235 } 236 237 .pattern :global(.dot) { 238 position: absolute; 239 width: 10px; 240 height: 10px; 241 background: rgba(0, 0, 0, 0.06); 242 border-radius: 50%; 243 transition: transform 0.04s linear; 244 } 245 246 @media (prefers-color-scheme: dark) { 247 .pattern :global(.dot) { 248 background: rgba(255, 255, 255, 0.1); 249 } 250 } 251 252 .pattern-fade { 253 position: fixed; 254 top: 0; 255 left: 0; 256 right: 0; 257 bottom: 0; 258 background: linear-gradient(135deg, transparent 50%, var(--bg-primary) 75%); 259 pointer-events: none; 260 z-index: 2; 261 } 262 263 @keyframes drift { 264 0% { transform: translateX(-500px); } 265 100% { transform: translateX(0); } 266 } 267 268 nav { 269 position: fixed; 270 top: 12px; 271 left: 32px; 272 right: 32px; 273 background: var(--accent); 274 padding: 10px 18px; 275 z-index: 100; 276 border-radius: var(--radius-xl); 277 display: flex; 278 justify-content: space-between; 279 align-items: center; 280 } 281 282 .nav-left { 283 display: flex; 284 align-items: center; 285 gap: var(--space-3); 286 } 287 288 .nav-logo { 289 height: 28px; 290 width: auto; 291 object-fit: contain; 292 border-radius: var(--radius-sm); 293 } 294 295 .hostname { 296 font-weight: var(--font-semibold); 297 font-size: var(--text-base); 298 letter-spacing: 0.08em; 299 color: var(--text-inverse); 300 text-transform: uppercase; 301 } 302 303 .hostname.placeholder { 304 opacity: 0.4; 305 } 306 307 .user-count { 308 font-size: var(--text-sm); 309 color: var(--text-inverse); 310 opacity: 0.85; 311 padding: 4px 10px; 312 background: rgba(255, 255, 255, 0.15); 313 border-radius: var(--radius-md); 314 white-space: nowrap; 315 } 316 317 @media (prefers-color-scheme: dark) { 318 .user-count { 319 background: rgba(0, 0, 0, 0.15); 320 } 321 } 322 323 .nav-meta { 324 font-size: var(--text-sm); 325 color: var(--text-inverse); 326 opacity: 0.6; 327 letter-spacing: 0.05em; 328 } 329 330 .home { 331 position: relative; 332 z-index: 10; 333 max-width: var(--width-xl); 334 margin: 0 auto; 335 padding: 72px 32px 32px; 336 } 337 338 .hero { 339 padding: var(--space-7) 0 var(--space-8); 340 border-bottom: 1px solid var(--border-color); 341 margin-bottom: var(--space-8); 342 } 343 344 h1 { 345 font-size: var(--text-4xl); 346 font-weight: var(--font-semibold); 347 line-height: var(--leading-tight); 348 margin-bottom: var(--space-6); 349 letter-spacing: -0.02em; 350 } 351 352 .cycling-word-container { 353 display: inline-block; 354 width: 3.9em; 355 text-align: left; 356 } 357 358 .cycling-word { 359 display: inline-block; 360 transition: opacity 0.1s ease, transform 0.1s ease; 361 } 362 363 .cycling-word.transitioning { 364 opacity: 0; 365 transform: scale(0.95); 366 } 367 368 .lede { 369 font-size: var(--text-xl); 370 font-weight: var(--font-medium); 371 color: var(--text-primary); 372 line-height: var(--leading-relaxed); 373 margin-bottom: 0; 374 } 375 376 .actions { 377 display: flex; 378 gap: var(--space-4); 379 margin-top: var(--space-7); 380 } 381 382 .btn { 383 font-size: var(--text-sm); 384 font-weight: var(--font-medium); 385 text-transform: uppercase; 386 letter-spacing: 0.06em; 387 padding: var(--space-4) var(--space-6); 388 border-radius: var(--radius-lg); 389 text-decoration: none; 390 transition: all var(--transition-normal); 391 border: 1px solid transparent; 392 } 393 394 .btn.primary { 395 background: var(--secondary); 396 color: var(--text-inverse); 397 border-color: var(--secondary); 398 } 399 400 .btn.primary:hover { 401 background: var(--secondary-hover); 402 border-color: var(--secondary-hover); 403 } 404 405 .btn.secondary { 406 background: transparent; 407 color: var(--text-primary); 408 border-color: var(--border-color); 409 } 410 411 .btn.secondary:hover { 412 background: var(--secondary-muted); 413 border-color: var(--secondary); 414 color: var(--secondary); 415 } 416 417 blockquote { 418 margin: var(--space-8) 0 0 0; 419 padding: var(--space-6); 420 background: var(--accent-muted); 421 border-left: 3px solid var(--accent); 422 border-radius: 0 var(--radius-xl) var(--radius-xl) 0; 423 } 424 425 blockquote p { 426 font-size: var(--text-lg); 427 color: var(--text-primary); 428 font-style: italic; 429 margin-bottom: var(--space-3); 430 } 431 432 blockquote cite { 433 font-size: var(--text-sm); 434 color: var(--text-secondary); 435 font-style: normal; 436 text-transform: uppercase; 437 letter-spacing: 0.05em; 438 } 439 440 .content h2 { 441 font-size: var(--text-sm); 442 font-weight: var(--font-bold); 443 text-transform: uppercase; 444 letter-spacing: 0.1em; 445 color: var(--accent-light); 446 margin: var(--space-8) 0 var(--space-5); 447 } 448 449 .content h2:first-child { 450 margin-top: 0; 451 } 452 453 .content > p { 454 font-size: var(--text-base); 455 color: var(--text-secondary); 456 margin-bottom: var(--space-5); 457 line-height: var(--leading-relaxed); 458 } 459 460 .features { 461 display: grid; 462 grid-template-columns: repeat(2, 1fr); 463 gap: var(--space-6); 464 margin: var(--space-6) 0 var(--space-8); 465 } 466 467 .feature { 468 padding: var(--space-5); 469 background: var(--bg-secondary); 470 border-radius: var(--radius-xl); 471 border: 1px solid var(--border-color); 472 } 473 474 .feature h3 { 475 font-size: var(--text-base); 476 font-weight: var(--font-semibold); 477 color: var(--text-primary); 478 margin-bottom: var(--space-3); 479 } 480 481 .feature p { 482 font-size: var(--text-sm); 483 color: var(--text-secondary); 484 margin: 0; 485 line-height: var(--leading-relaxed); 486 } 487 488 @media (max-width: 700px) { 489 .features { 490 grid-template-columns: 1fr; 491 } 492 493 h1 { 494 font-size: var(--text-3xl); 495 } 496 497 .actions { 498 flex-direction: column; 499 } 500 501 .btn { 502 text-align: center; 503 } 504 505 .user-count, 506 .nav-meta { 507 display: none; 508 } 509 } 510 511 .site-footer { 512 margin-top: var(--space-9); 513 padding-top: var(--space-7); 514 display: flex; 515 justify-content: space-between; 516 font-size: var(--text-sm); 517 color: var(--text-muted); 518 text-transform: uppercase; 519 letter-spacing: 0.05em; 520 border-top: 1px solid var(--border-color); 521 } 522</style>