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>