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