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