Copy and customize this complete HTML template for your PDS landing page with live feed functionality.
/pds/www/index.html on your PDS server<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>your-domain.com - Personal Data Server</title>
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png">
<link rel="manifest" href="site.webmanifest">
<link rel="icon" href="favicon.ico" type="image/x-icon">
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'JetBrains Mono', 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace;
font-weight: 300;
background: #000;
color: #fff;
line-height: 1.6;
min-height: 100vh;
display: flex;
flex-direction: column;
padding: 20px;
position: relative;
}
.container {
max-width: 800px;
margin: 0 auto;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
z-index: 2;
}
.header {
text-align: center;
margin-bottom: 3rem;
}
.header h1 {
font-size: clamp(1.5rem, 4vw, 2.5rem);
margin-bottom: 1rem;
font-weight: 400;
}
.header .subtitle {
font-size: clamp(0.9rem, 2vw, 1.1rem);
font-weight: 300;
}
.atproto-section {
text-align: center;
margin: 3rem 0;
}
.ascii-art {
font-family: 'JetBrains Mono', monospace;
font-size: clamp(0.5rem, 1.8vw, 1rem);
font-weight: 300;
white-space: pre;
margin-bottom: 2rem;
line-height: 1.2;
text-align: left;
display: inline-block;
}
.atproto-info p {
margin-bottom: 1rem;
font-size: clamp(0.9rem, 2vw, 1.1rem);
font-weight: 300;
}
.links {
margin-top: 1rem;
}
.links a {
color: #fff;
text-decoration: underline;
margin: 0 1rem;
font-weight: 300;
transition: opacity 0.2s ease;
}
.links a:hover {
text-decoration: none;
opacity: 0.8;
}
.personal-section {
margin-top: 4rem;
text-align: center;
}
.personal-section h2 {
margin-bottom: 2rem;
font-size: clamp(1.2rem, 3vw, 1.8rem);
font-weight: 400;
}
.accounts {
display: flex;
justify-content: center;
gap: 2rem;
margin: 2rem 0;
text-align: center;
}
.account {
padding: 1rem 1.5rem;
border: 1px solid #333;
transition: border-color 0.2s ease;
width: fit-content;
min-width: 280px;
}
.account:hover {
border-color: #666;
}
.account .handle {
font-weight: 400;
margin-bottom: 0.5rem;
}
.account .handle a {
color: #fff;
text-decoration: none;
transition: opacity 0.2s ease;
}
.account .handle a:hover {
text-decoration: underline;
opacity: 0.8;
}
.account .description {
font-weight: 300;
opacity: 0.8;
}
.links-section {
margin-top: 3rem;
text-align: center;
}
.external-link {
display: inline-block;
padding: 1rem 2rem;
border: 1px solid #333;
color: #fff;
text-decoration: none;
font-weight: 300;
transition: all 0.2s ease;
}
.external-link:hover {
border-color: #666;
background: rgba(255, 255, 255, 0.05);
}
.footer {
text-align: center;
margin-top: 3rem;
padding-top: 2rem;
border-top: 1px solid #333;
font-size: 0.9rem;
font-weight: 300;
opacity: 0.7;
position: relative;
z-index: 2;
}
/* Live Feed Styles */
.live-feed {
position: fixed;
top: 20px;
right: 20px;
width: 300px;
max-height: calc(100vh - 40px);
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(10px);
border: 1px solid #333;
border-radius: 8px;
overflow: hidden;
z-index: 1000;
display: flex;
flex-direction: column;
}
.live-feed-header {
padding: 1rem;
border-bottom: 1px solid #333;
background: rgba(0, 0, 0, 0.9);
}
.live-feed-title {
font-size: 0.9rem;
font-weight: 400;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.live-indicator {
width: 8px;
height: 8px;
background: #00ff00;
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
.feed-status {
font-size: 0.7rem;
opacity: 0.7;
}
.live-feed-content {
flex: 1;
overflow-y: auto;
padding: 0;
}
.feed-post {
padding: 1rem;
border-bottom: 1px solid #333;
transition: background-color 0.2s ease;
}
.feed-post:hover {
background: rgba(255, 255, 255, 0.05);
}
.feed-post:last-child {
border-bottom: none;
}
.post-author {
font-size: 0.8rem;
font-weight: 400;
margin-bottom: 0.5rem;
color: #aaa;
}
.post-content {
font-size: 0.8rem;
font-weight: 300;
line-height: 1.4;
margin-bottom: 0.5rem;
}
.post-time {
font-size: 0.7rem;
opacity: 0.6;
}
.post-images {
margin: 0.5rem 0;
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.post-image {
max-width: 100%;
max-height: 80px;
border-radius: 4px;
object-fit: cover;
transition: transform 0.2s ease;
}
.post-image:hover {
transform: scale(1.05);
}
.loading {
padding: 2rem;
text-align: center;
font-size: 0.8rem;
opacity: 0.7;
}
.error {
padding: 2rem;
text-align: center;
font-size: 0.8rem;
color: #ff6b6b;
}
/* Toggle button for mobile */
.feed-toggle {
display: none;
position: fixed;
top: 10px;
right: 10px;
z-index: 1001;
background: rgba(0, 0, 0, 0.8);
border: 1px solid #333;
color: #fff;
padding: 0.5rem 0.7rem;
border-radius: 4px;
font-family: inherit;
font-size: 0.7rem;
cursor: pointer;
backdrop-filter: blur(5px);
}
.feed-toggle:hover {
background: rgba(255, 255, 255, 0.1);
}
/* Responsive Design */
@media (max-width: 1400px) {
.live-feed {
width: 280px;
}
}
@media (max-width: 1200px) {
.container {
margin-right: 300px;
}
}
@media (max-width: 1024px) {
.container {
margin-right: 0;
}
.live-feed {
display: none;
}
.feed-toggle {
display: block;
}
.live-feed.mobile-visible {
display: flex;
width: calc(100vw - 40px);
height: 60vh;
top: 60px;
left: 20px;
right: 20px;
}
}
@media (max-width: 768px) {
body {
padding: 10px;
padding-top: 50px;
}
.header h1 {
margin-top: 1rem;
}
.ascii-art {
font-size: 0.4rem;
}
.accounts {
justify-content: center;
text-align: center;
}
.links a {
display: block;
margin: 0.5rem 0;
}
.external-link {
padding: 0.8rem 1.5rem;
}
.feed-toggle {
top: 10px;
right: 10px;
font-size: 0.6rem;
padding: 0.4rem 0.6rem;
}
.live-feed.mobile-visible {
top: 50px;
width: calc(100vw - 20px);
right: 10px;
left: 10px;
height: 50vh;
}
}
/* Custom scrollbar for feed */
.live-feed-content::-webkit-scrollbar {
width: 4px;
}
.live-feed-content::-webkit-scrollbar-track {
background: transparent;
}
.live-feed-content::-webkit-scrollbar-thumb {
background: #333;
border-radius: 2px;
}
.live-feed-content::-webkit-scrollbar-thumb:hover {
background: #555;
}
</style>
</head>
<body>
<!-- Live Feed -->
<button class="feed-toggle" onclick="toggleFeed()">📡 Live</button>
<div class="live-feed" id="liveFeed">
<div class="live-feed-header">
<div class="live-feed-title">
<span class="live-indicator"></span>
Live Feed
</div>
<div class="feed-status" id="feedStatus">Loading...</div>
</div>
<div class="live-feed-content" id="feedContent">
<div class="loading">Loading recent posts...</div>
</div>
</div>
<div class="container">
<header class="header">
<h1>your-domain.com</h1>
</header>
<section class="atproto-section">
<pre class="ascii-art"> __ __
/\ \__ /\ \__
__ \ \ ,_\ _____ _ __ ___\ \ ,_\ ___
/'__'\ \ \ \/ /\ '__'\/\''__\/ __'\ \ \/ / __'\
/\ \L\.\_\ \ \_\ \ \L\ \ \ \//\ \L\ \ \ \_/\ \L\ \
\ \__/.\_\\ \__\\ \ ,__/\ \_\\ \____/\ \__\ \____/
\/__/\/_/ \/__/ \ \ \/ \/_/ \/___/ \/__/\/___/
\ \_\
\/_/</pre>
<div class="atproto-info">
<p>This is an AT Protocol Personal Data Server (aka, an atproto PDS), self hosted by [YOUR NAME].</p>
<div class="links">
<a href="https://github.com/bluesky-social/atproto" target="_blank">Code</a>
<a href="https://github.com/bluesky-social/pds" target="_blank">Self-Host</a>
<a href="https://atproto.com" target="_blank">Protocol</a>
<a href="https://your-website.com" target="_blank">Your Website</a>
</div>
</div>
</section>
<section class="personal-section">
<h2>Admin:</h2>
<div class="accounts">
<div class="account">
<p class="handle"><a href="https://bsky.app/profile/your-handle.your-domain.com" target="_blank">@your-handle.your-domain.com</a></p>
<p class="description">Primary social account</p>
</div>
</div>
<p class="links">send a DM if you'd like an invite code in order to migrate via <a href="https://pdsmoover.com" target="_blank">pds moover</a></p>
<div class="links-section">
<a href="https://your-links-page.com" target="_blank" class="external-link">🔗 More Links</a>
</div>
</section>
</div>
<footer class="footer">
<p>Powered by AT Protocol</p>
</footer>
<script>
// PDS API Configuration
const PDS_HOST = 'https://your-domain.com';
const ACCOUNTS = [
'did:plc:YOUR_DID_HERE', // your-handle
// Add more account DIDs here
];
const HANDLE_MAP = {
'did:plc:YOUR_DID_HERE': '@your-handle.your-domain.com',
// Add more DID to handle mappings here
};
let lastUpdateTime = new Date();
let feedVisible = window.innerWidth > 1024;
// Mobile feed toggle
function toggleFeed() {
const feed = document.getElementById('liveFeed');
feed.classList.toggle('mobile-visible');
}
// Format relative time
function formatRelativeTime(date) {
const now = new Date();
const diffInSeconds = Math.floor((now - date) / 1000);
if (diffInSeconds < 60) return 'just now';
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`;
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`;
return `${Math.floor(diffInSeconds / 86400)}d ago`;
}
// Truncate long text and format line breaks
function truncateText(text, maxLength = 150) {
if (!text) return '';
let formatted = text.replace(/\n/g, '<br>');
if (formatted.length <= maxLength) return formatted;
return formatted.substring(0, maxLength) + '...';
}
// Check if post is a reply
function isReply(record) {
return record.value.reply !== undefined;
}
// Extract images from post record
function extractImages(record) {
const images = [];
if (record.value.embed && record.value.embed.images) {
record.value.embed.images.forEach(img => {
if (img.image && img.image.ref) {
images.push({
alt: img.alt || '',
url: `${PDS_HOST}/xrpc/com.atproto.sync.getBlob?did=${record.uri.split('/')[2]}&cid=${img.image.ref.$link}`
});
}
});
}
return images;
}
// Fetch posts from a specific account
async function fetchAccountPosts(did) {
try {
const response = await fetch(`${PDS_HOST}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=10`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
// Filter out replies and map to our format
return data.records
.filter(record => !isReply(record)) // Exclude replies
.map(record => ({
did: did,
handle: HANDLE_MAP[did] || did,
content: record.value.text || '',
createdAt: new Date(record.value.createdAt),
uri: record.uri,
images: extractImages(record)
}));
} catch (error) {
console.warn(`Failed to fetch posts from ${did}:`, error);
return [];
}
}
// Fetch all posts from all accounts
async function fetchAllPosts() {
try {
const allPostsPromises = ACCOUNTS.map(fetchAccountPosts);
const allPostsArrays = await Promise.all(allPostsPromises);
// Flatten and sort by creation time
const allPosts = allPostsArrays.flat().sort((a, b) => b.createdAt - a.createdAt);
return allPosts.slice(0, 10); // Keep latest 10 posts
} catch (error) {
console.error('Failed to fetch posts:', error);
return [];
}
}
// Render posts to the feed
function renderPosts(posts) {
const feedContent = document.getElementById('feedContent');
const feedStatus = document.getElementById('feedStatus');
if (posts.length === 0) {
feedContent.innerHTML = '<div class="error">No recent posts found</div>';
feedStatus.textContent = 'No posts available';
return;
}
const postsHTML = posts.map(post => {
const imagesHTML = post.images && post.images.length > 0
? `<div class="post-images">${post.images.map(img =>
`<img src="${img.url}" alt="${img.alt}" class="post-image" loading="lazy">`
).join('')}</div>`
: '';
return `
<div class="feed-post">
<div class="post-author">${post.handle}</div>
<div class="post-content">${truncateText(post.content)}</div>
${imagesHTML}
<div class="post-time">${formatRelativeTime(post.createdAt)}</div>
</div>
`;
}).join('');
feedContent.innerHTML = postsHTML;
feedStatus.textContent = `Last updated: ${formatRelativeTime(lastUpdateTime)}`;
}
// Update the feed
async function updateFeed() {
try {
const posts = await fetchAllPosts();
renderPosts(posts);
lastUpdateTime = new Date();
} catch (error) {
console.error('Feed update failed:', error);
document.getElementById('feedContent').innerHTML =
'<div class="error">Failed to load posts. Retrying...</div>';
}
}
// Initialize the feed
async function initFeed() {
await updateFeed();
// Update every 30 seconds
setInterval(updateFeed, 30000);
// Update timestamps every 10 seconds
setInterval(() => {
const timeElements = document.querySelectorAll('.post-time');
const posts = Array.from(document.querySelectorAll('.feed-post'));
posts.forEach((postEl, index) => {
const timeEl = timeElements[index];
if (timeEl && timeEl.dataset.createdAt) {
const createdAt = new Date(timeEl.dataset.createdAt);
timeEl.textContent = formatRelativeTime(createdAt);
}
});
const feedStatus = document.getElementById('feedStatus');
feedStatus.textContent = `Last updated: ${formatRelativeTime(lastUpdateTime)}`;
}, 10000);
}
// Handle responsive behavior
function handleResize() {
const feed = document.getElementById('liveFeed');
if (window.innerWidth > 1024) {
feed.classList.remove('mobile-visible');
feedVisible = true;
} else {
feedVisible = feed.classList.contains('mobile-visible');
}
}
// Initialize everything when page loads
window.addEventListener('load', initFeed);
window.addEventListener('resize', handleResize);
// Handle visibility changes to pause/resume updates
document.addEventListener('visibilitychange', function() {
if (!document.hidden && feedVisible) {
updateFeed();
}
});
</script>
</body>
</html>
Replace the following placeholders in the template:
// 1. Update your domain name (multiple places)
your-domain.com → yourdomain.com
// 2. Update the PDS_HOST in JavaScript
const PDS_HOST = 'https://yourdomain.com';
// 3. Add your account DID(s)
const ACCOUNTS = [
'did:plc:YOUR_ACTUAL_DID_HERE',
];
// 4. Update handle mapping
const HANDLE_MAP = {
'did:plc:YOUR_ACTUAL_DID_HERE': '@yourhandle.yourdomain.com',
};
// 5. Update your information
[YOUR NAME] → Your Actual Name
@your-handle.your-domain.com → @yourhandle.yourdomain.com
https://your-website.com → https://youractualwebsite.com
Visit https://yourdomain.com/xrpc/com.atproto.identity.resolveHandle?handle=yourhandle.yourdomain.com to find your account's DID.