Files
tranquil-pds/frontend/public/homepage.html
Lewis c680f3c419 fix(homepage): favicon should render in title
# I am sorry I forgot this.

Now pds.ls will show beautiful icons when showing Tranquil PDSes.
2026-03-14 11:57:01 +02:00

646 lines
19 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/favicon.ico" type="image/png">
<title>Tranquil PDS</title>
<style>
:root {
--space-0: 0;
--space-1: 0.125rem;
--space-2: 0.25rem;
--space-3: 0.5rem;
--space-4: 0.75rem;
--space-5: 1rem;
--space-6: 1.5rem;
--space-7: 2rem;
--space-8: 3rem;
--space-9: 4rem;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 2rem;
--text-4xl: 2.5rem;
--font-normal: 400;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
--leading-tight: 1.25;
--leading-normal: 1.5;
--leading-relaxed: 1.75;
--radius-sm: 3px;
--radius-md: 4px;
--radius-lg: 6px;
--radius-xl: 8px;
--width-xs: 360px;
--width-sm: 480px;
--width-md: 760px;
--width-lg: 960px;
--width-xl: 1100px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.15);
--shadow-focus: 0 0 0 2px var(--accent-muted);
--transition-fast: 0.1s ease;
--transition-normal: 0.15s ease;
--transition-slow: 0.25s ease;
--font-mono: ui-monospace, "SF Mono", Menlo, Monaco, monospace;
--bg-primary: #f9fafa;
--bg-secondary: #f1f3f3;
--bg-tertiary: #e8ebeb;
--bg-hover: #e8ebeb;
--bg-card: #ffffff;
--bg-input: #ffffff;
--bg-input-disabled: #f1f3f3;
--text-primary: #1a1d1d;
--text-secondary: #5a605f;
--text-muted: #8a8f8e;
--text-inverse: #ffffff;
--border-color: #dce0df;
--border-light: #e8ebeb;
--border-dark: #c8cecc;
--accent: #1a1d1d;
--accent-hover: #2e3332;
--accent-muted: rgba(26, 29, 29, 0.06);
--accent-light: #3a403f;
--secondary: #1a1d1d;
--secondary-hover: #2e3332;
--secondary-muted: rgba(26, 29, 29, 0.06);
--success-bg: #dfd;
--success-border: #8c8;
--success-text: #060;
--error-bg: #fee;
--error-border: #fcc;
--error-text: #c00;
--warning-bg: #ffd;
--warning-border: #d4a03c;
--warning-text: #856404;
--border-color-light: var(--border-dark);
}
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #0a0c0c;
--bg-secondary: #131616;
--bg-tertiary: #1a1d1d;
--bg-hover: #1a1d1d;
--bg-card: #131616;
--bg-input: #1a1d1d;
--bg-input-disabled: #131616;
--text-primary: #e6e8e8;
--text-secondary: #9ca1a0;
--text-muted: #686d6c;
--text-inverse: #0a0c0c;
--border-color: #282c2b;
--border-light: #1f2322;
--border-dark: #343938;
--accent: #e6e8e8;
--accent-hover: #ffffff;
--accent-muted: rgba(230, 232, 232, 0.1);
--accent-light: #ffffff;
--secondary: #e6e8e8;
--secondary-hover: #ffffff;
--secondary-muted: rgba(230, 232, 232, 0.1);
--success-bg: #0f1f1a;
--success-border: #1a3d2d;
--success-text: #7bc6a0;
--error-bg: #1f0f0f;
--error-border: #3d1a1a;
--error-text: #ff8a8a;
--warning-bg: #1f1a0f;
--warning-border: #3d351a;
--warning-text: #c6b87b;
}
}
*, *::before, *::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family:
system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: var(--leading-normal);
-webkit-font-smoothing: antialiased;
}
.pattern-canvas {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1;
}
.pattern-fade {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
135deg,
transparent 50%,
var(--bg-primary) 75%
);
pointer-events: none;
z-index: 2;
}
nav {
position: fixed;
top: 12px;
left: 32px;
right: 32px;
background: var(--accent);
padding: 10px 18px;
z-index: 100;
border-radius: var(--radius-xl);
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-left {
display: flex;
align-items: center;
gap: var(--space-3);
}
.nav-logo {
height: 28px;
width: auto;
object-fit: contain;
border-radius: var(--radius-sm);
}
.hostname {
font-weight: var(--font-semibold);
font-size: var(--text-base);
letter-spacing: 0.08em;
color: var(--text-inverse);
text-transform: uppercase;
}
.hostname.placeholder {
opacity: 0.4;
}
.user-count {
font-size: var(--text-sm);
color: var(--text-inverse);
opacity: 0.85;
padding: 4px 10px;
background: rgba(255, 255, 255, 0.15);
border-radius: var(--radius-md);
white-space: nowrap;
}
@media (prefers-color-scheme: dark) {
.user-count {
background: rgba(0, 0, 0, 0.15);
}
}
.nav-meta {
font-size: var(--text-sm);
color: var(--text-inverse);
opacity: 0.6;
letter-spacing: 0.05em;
}
.home {
position: relative;
z-index: 10;
max-width: var(--width-xl);
margin: 0 auto;
padding: 72px 32px 32px;
}
.hero {
padding: var(--space-7) 0 var(--space-6);
margin-bottom: var(--space-5);
}
h1 {
font-size: var(--text-4xl);
font-weight: var(--font-semibold);
line-height: var(--leading-tight);
margin-bottom: var(--space-6);
letter-spacing: -0.02em;
}
.cycling-word-container {
display: inline-block;
width: 3.9em;
text-align: left;
}
.cycling-word {
display: inline-block;
transition: opacity 0.1s ease, transform 0.1s ease;
}
.cycling-word.transitioning {
opacity: 0;
transform: scale(0.95);
}
.lede {
font-size: var(--text-xl);
font-weight: var(--font-medium);
color: var(--text-primary);
line-height: var(--leading-relaxed);
margin-bottom: 0;
}
.actions {
display: flex;
gap: var(--space-4);
margin-top: var(--space-7);
}
.btn {
font-size: var(--text-sm);
font-weight: var(--font-medium);
text-transform: uppercase;
letter-spacing: 0.06em;
padding: var(--space-4) var(--space-6);
border-radius: var(--radius-lg);
text-decoration: none;
transition: all var(--transition-normal);
border: 1px solid transparent;
}
.btn.primary {
background: var(--accent);
color: var(--text-inverse);
border-color: var(--accent);
}
.btn.primary:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.btn.secondary {
background: var(--bg-primary);
color: var(--text-primary);
border-color: var(--border-color);
}
.btn.secondary:hover {
background: var(--secondary-muted);
border-color: var(--secondary);
color: var(--secondary);
}
blockquote {
margin: var(--space-8) 0 0 0;
padding: var(--space-6);
background: var(--accent-muted);
border-left: 3px solid var(--accent);
border-radius: 0 var(--radius-xl) var(--radius-xl) 0;
}
blockquote p {
font-size: var(--text-lg);
color: var(--text-primary);
font-style: italic;
margin-bottom: var(--space-3);
}
blockquote cite {
font-size: var(--text-sm);
color: var(--text-secondary);
font-style: normal;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.content h2 {
font-size: var(--text-sm);
font-weight: var(--font-bold);
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent-light);
margin: var(--space-8) 0 var(--space-5);
}
.content h2:first-child {
margin-top: 0;
}
.content > p {
font-size: var(--text-base);
color: var(--text-secondary);
margin-bottom: var(--space-5);
line-height: var(--leading-relaxed);
}
.feature {
padding: var(--space-6);
background: var(--bg-secondary);
border-radius: var(--radius-xl);
border: 1px solid var(--border-color);
margin: var(--space-6) 0 var(--space-8);
}
.feature p {
font-size: var(--text-base);
color: var(--text-secondary);
margin: 0;
line-height: var(--leading-relaxed);
}
@media (max-width: 700px) {
h1 {
font-size: var(--text-3xl);
}
.actions {
flex-direction: column;
}
.btn {
text-align: center;
}
.user-count, .nav-meta {
display: none;
}
}
.site-footer {
margin-top: var(--space-9);
padding-top: var(--space-7);
display: flex;
justify-content: space-between;
font-size: var(--text-sm);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
border-top: 1px solid var(--border-color);
}
.hidden {
display: none !important;
}
</style>
</head>
<body>
<canvas class="pattern-canvas" id="patternCanvas"></canvas>
<div class="pattern-fade"></div>
<nav>
<div class="nav-left">
<img src="/favicon.ico" alt="Logo" class="nav-logo hidden" id="navLogo">
<span class="hostname" id="hostname">loading...</span>
<span class="user-count hidden" id="userCount"></span>
</div>
<span class="nav-meta" id="version"></span>
</nav>
<div class="home">
<section class="hero">
<h1>
A home for your <span class="cycling-word-container"><span
class="cycling-word"
id="cyclingWord"
>Bluesky</span></span> account
</h1>
<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>
<div class="actions" id="heroActions">
<a href="/app/register" class="btn primary" id="heroPrimary"
>Join This Server</a>
<a href="/app/login" class="btn secondary" id="heroLogin">Login</a>
<a
href="https://tangled.org/tranquil.farm/tranquil-pds"
class="btn secondary"
id="heroSecondary"
target="_blank"
rel="noopener"
>Run Your Own</a>
</div>
<blockquote>
<p>"Nature does not hurry, yet everything is accomplished."</p>
<cite>Lao Tzu</cite>
</blockquote>
</section>
<section class="content">
<h2>What you get</h2>
<div class="feature">
<p>
A superset of the reference PDS: passkeys and 2FA (TOTP, backup
codes, trusted devices), SSO login and signup, did:web support
(PDS-hosted subdomains or bring-your-own), multi-channel
notifications (email, Discord, Telegram, Signal) for verification
and alerts, granular OAuth scopes with human-readable descriptions,
app passwords with configurable permissions (read-only, post-only,
or custom scopes), account delegation with permission levels and
audit logging, and a built-in web UI for account management,
repo browsing, and admin.
</p>
</div>
</section>
<footer class="site-footer">
<span>Made by people who don't take themselves too seriously</span>
<span>Open source & open hearts</span>
</footer>
</div>
<script>
(function checkSession() {
try {
var stored = localStorage.getItem("tranquil_pds_session");
if (stored) {
var session = JSON.parse(stored);
if (session && session.handle) {
var heroPrimary = document.getElementById("heroPrimary");
var heroLogin = document.getElementById("heroLogin");
var heroSecondary = document.getElementById(
"heroSecondary",
);
if (heroPrimary) {
heroPrimary.href = "/app/dashboard";
heroPrimary.textContent = "@" + session.handle;
}
if (heroLogin) heroLogin.classList.add("hidden");
if (heroSecondary) heroSecondary.classList.add("hidden");
}
}
} catch (e) {}
})();
const heroWords = ["Bluesky", "Tangled", "Leaflet", "ATProto"];
const wordSpacing = {
"Bluesky": "0.01em",
"Tangled": "0.02em",
"Leaflet": "0.05em",
"ATProto": "0",
};
let currentWordIndex = 0;
const cyclingWord = document.getElementById("cyclingWord");
function cycleWord() {
cyclingWord.classList.add("transitioning");
setTimeout(() => {
currentWordIndex = (currentWordIndex + 1) % heroWords.length;
const word = heroWords[currentWordIndex];
cyclingWord.textContent = word;
cyclingWord.style.letterSpacing = wordSpacing[word] || "0";
cyclingWord.classList.remove("transitioning");
const duration = word === "ATProto" ? 4000 : 2000;
setTimeout(cycleWord, duration);
}, 100);
}
setTimeout(cycleWord, 2000);
fetch("/xrpc/com.atproto.server.describeServer")
.then(function (r) {
return r.json();
})
.then(function (info) {
var hostnameEl = document.getElementById("hostname");
hostnameEl.textContent = window.location.hostname;
hostnameEl.classList.remove("placeholder");
if (info.version) {
document.getElementById("version").textContent =
info.version;
}
})
.catch(function () {
var hostnameEl = document.getElementById("hostname");
hostnameEl.textContent = window.location.hostname;
hostnameEl.classList.remove("placeholder");
});
(function loadServerColors() {
var darkMode =
window.matchMedia("(prefers-color-scheme: dark)").matches;
var root = document.documentElement;
function applyColors(config) {
if (darkMode) {
if (config.primaryColorDark) {
root.style.setProperty(
"--accent",
config.primaryColorDark,
);
}
if (config.secondaryColorDark) {
root.style.setProperty(
"--secondary",
config.secondaryColorDark,
);
}
} else {
if (config.primaryColor) {
root.style.setProperty("--accent", config.primaryColor);
}
if (config.secondaryColor) {
root.style.setProperty(
"--secondary",
config.secondaryColor,
);
}
}
}
fetch("/xrpc/_server.getConfig")
.then(function (r) {
return r.json();
})
.then(function (config) {
applyColors(config);
window.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", function (e) {
darkMode = e.matches;
applyColors(config);
});
})
.catch(function () {});
})();
fetch("/xrpc/com.atproto.sync.listRepos?limit=1000")
.then((r) => r.json())
.then((data) => {
const count = data.repos?.length || 0;
const el = document.getElementById("userCount");
el.textContent = count + " " +
(count === 1 ? "user" : "users");
el.classList.remove("hidden");
})
.catch(() => {});
fetch("/favicon.ico", { method: "HEAD" })
.then((r) => {
if (r.ok) {
document.getElementById("navLogo").classList.remove(
"hidden",
);
}
})
.catch(() => {});
(function initPattern() {
var canvas = document.getElementById("patternCanvas");
var ctx = canvas.getContext("2d");
var dpr = window.devicePixelRatio || 1;
var spacing = 32;
var baseRadius = 5;
var maxDist = 120;
var driftSpeed = 500 / 80;
var driftOffset = 0;
var mouseX = -1000;
var mouseY = -1000;
var darkMode =
window.matchMedia("(prefers-color-scheme: dark)").matches;
function resize() {
canvas.width = window.innerWidth * dpr;
canvas.height = window.innerHeight * dpr;
canvas.style.width = window.innerWidth + "px";
canvas.style.height = window.innerHeight + "px";
ctx.scale(dpr, dpr);
}
resize();
window.addEventListener("resize", resize);
window.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", function (e) {
darkMode = e.matches;
});
document.addEventListener("mousemove", function (e) {
mouseX = e.clientX;
mouseY = e.clientY;
});
document.addEventListener("mouseleave", function () {
mouseX = -1000;
mouseY = -1000;
});
var lastTime = 0;
function draw(time) {
var dt = lastTime ? (time - lastTime) / 1000 : 0;
lastTime = time;
driftOffset = (driftOffset + driftSpeed * dt) % spacing;
ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
var fillColor = darkMode
? "rgba(255, 255, 255, 0.1)"
: "rgba(0, 0, 0, 0.06)";
ctx.fillStyle = fillColor;
var startX = -spacing + driftOffset;
var startY = -spacing;
var endX = window.innerWidth + spacing;
var endY = window.innerHeight + spacing;
for (var y = startY; y < endY; y += spacing) {
for (var x = startX; x < endX; x += spacing) {
var dist = Math.hypot(mouseX - x, mouseY - y);
var scale = Math.min(1, Math.max(0.1, dist / maxDist));
var radius = baseRadius * scale;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
}
}
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
})();
</script>
</body>
</html>