← Back to Setup Guide

PDS Landing Page Template

Copy and customize this complete HTML template for your PDS landing page with live feed functionality.

Complete HTML Template

Instructions:
<!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>

Customization Instructions

Replace the following placeholders in the template:

Required Changes

// 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
How to find your DID:

Visit https://yourdomain.com/xrpc/com.atproto.identity.resolveHandle?handle=yourhandle.yourdomain.com to find your account's DID.