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>