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>