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>