chore: moved post loading into tsx component cfdbebd6
Steve · 2026-01-08 19:15 4 file(s) · +375 −263
packages/client/src/components/NowUpdates.tsx (added) +181 −0
1 +
import { useEffect, useState } from "react";
2 +
import MarkdownIt from "markdown-it";
3 +
4 +
const md = new MarkdownIt({
5 +
	html: false,
6 +
	linkify: true,
7 +
	typographer: true,
8 +
});
9 +
10 +
const DID = "did:plc:ia2zdnhjaokf5lazhxrmj6eu";
11 +
const PDS_URL = "https://polybius.social";
12 +
13 +
interface Record {
14 +
	uri: string;
15 +
	value: any;
16 +
	type: "document" | "post";
17 +
}
18 +
19 +
export default function NowUpdates() {
20 +
	const [content, setContent] = useState<string>("<p>Loading...</p>");
21 +
22 +
	useEffect(() => {
23 +
		async function fetchPosts() {
24 +
			try {
25 +
				// Fetch new site.standard.document records
26 +
				const documentsPromise = fetch(
27 +
					`${PDS_URL}/xrpc/com.atproto.repo.listRecords?` +
28 +
						new URLSearchParams({
29 +
							repo: DID,
30 +
							collection: "site.standard.document",
31 +
							limit: "20",
32 +
						}),
33 +
				)
34 +
					.then((res) => (res.ok ? res.json() : { records: [] }))
35 +
					.catch(() => ({ records: [] }));
36 +
37 +
				// Fetch old app.bsky.feed.post records for backward compatibility
38 +
				const postsPromise = fetch(
39 +
					`${PDS_URL}/xrpc/com.atproto.repo.listRecords?` +
40 +
						new URLSearchParams({
41 +
							repo: DID,
42 +
							collection: "app.bsky.feed.post",
43 +
							limit: "20",
44 +
							filter: "posts_no_replies",
45 +
						}),
46 +
				)
47 +
					.then((res) => (res.ok ? res.json() : { records: [] }))
48 +
					.catch(() => ({ records: [] }));
49 +
50 +
				const [documentsData, postsData] = await Promise.all([
51 +
					documentsPromise,
52 +
					postsPromise,
53 +
				]);
54 +
55 +
				// Combine and normalize records
56 +
				const documents = (documentsData.records || []).map((record: any) => ({
57 +
					...record,
58 +
					type: "document",
59 +
				}));
60 +
61 +
				const posts = (postsData.records || [])
62 +
					.filter((record: any) => !record.value.reply)
63 +
					.map((record: any) => ({
64 +
						...record,
65 +
						type: "post",
66 +
					}));
67 +
68 +
				// Combine all records and sort by date
69 +
				const allRecords: Record[] = [...documents, ...posts].sort((a, b) => {
70 +
					const dateA = new Date(a.value.publishedAt || a.value.createdAt);
71 +
					const dateB = new Date(b.value.publishedAt || b.value.createdAt);
72 +
					return dateB.getTime() - dateA.getTime(); // Most recent first
73 +
				});
74 +
75 +
				if (allRecords.length === 0) {
76 +
					setContent("<p>No recent updates found.</p>");
77 +
					return;
78 +
				}
79 +
80 +
				const postsHTML = allRecords
81 +
					.map((record) => {
82 +
						const value = record.value;
83 +
						const rkey = record.uri.split("/").pop();
84 +
85 +
						// Render based on record type
86 +
						if (record.type === "document") {
87 +
							// site.standard.document
88 +
							const publishedAt = new Date(
89 +
								value.publishedAt,
90 +
							).toLocaleDateString();
91 +
92 +
							// Extract markdown content
93 +
							let contentHTML = "";
94 +
							if (value.content && value.content.markdown) {
95 +
								contentHTML = md.render(value.content.markdown);
96 +
							} else if (value.textContent) {
97 +
								contentHTML = `<p>${value.textContent}</p>`;
98 +
							}
99 +
100 +
							return `
101 +
              <a href="/pds?rkey=${rkey}" class="block border-b pb-6 mb-6 last:border-b-0">
102 +
                <article>
103 +
                  <h3 class="text-lg font-semibold mb-3">${value.title}</h3>
104 +
                  <div class="prose prose-invert max-w-none mb-3">
105 +
                    ${contentHTML}
106 +
                  </div>
107 +
                  <div class="flex items-center gap-2 text-sm text-gray-500">
108 +
                    <time>${publishedAt}</time>
109 +
                  </div>
110 +
                </article>
111 +
              </a>
112 +
            `;
113 +
						} else {
114 +
							// app.bsky.feed.post (backward compatibility)
115 +
							const createdAt = new Date(value.createdAt).toLocaleDateString();
116 +
117 +
							// Handle images
118 +
							let imagesHTML = "";
119 +
							if (
120 +
								value.embed &&
121 +
								value.embed.$type === "app.bsky.embed.images" &&
122 +
								value.embed.images
123 +
							) {
124 +
								const imageElements = value.embed.images
125 +
									.map((image: any) => {
126 +
										const blobUrl =
127 +
											`${PDS_URL}/xrpc/com.atproto.sync.getBlob?` +
128 +
											new URLSearchParams({
129 +
												did: DID,
130 +
												cid: image.image.ref.$link,
131 +
											});
132 +
133 +
										return `
134 +
                  <img
135 +
                    src="${blobUrl}"
136 +
                    alt="${image.alt || "Image from post"}"
137 +
                    class="max-w-full h-auto"
138 +
                    loading="lazy"
139 +
                  />
140 +
                `;
141 +
									})
142 +
									.join("");
143 +
144 +
								imagesHTML = `
145 +
                <div class="mt-3 grid gap-2 ${value.embed.images.length === 1 ? "grid-cols-1" : "grid-cols-2"}">
146 +
                  ${imageElements}
147 +
                </div>
148 +
              `;
149 +
							}
150 +
151 +
							return `
152 +
              <a href="/pds?rkey=${rkey}" class="block border-b pb-6 mb-6 last:border-b-0">
153 +
                <article>
154 +
                  <p class="mb-2">${value.text}</p>
155 +
                  ${imagesHTML}
156 +
                  <time class="text-sm text-gray-500 mt-2 block">${createdAt}</time>
157 +
                </article>
158 +
              </a>
159 +
            `;
160 +
						}
161 +
					})
162 +
					.join("");
163 +
164 +
				setContent(`
165 +
          <div class="space-y-4">
166 +
            <div>${postsHTML}</div>
167 +
          </div>
168 +
        `);
169 +
			} catch (err) {
170 +
				console.error("Error fetching updates:", err);
171 +
				setContent(
172 +
					"<p>Error loading recent updates. Make sure your PDS is accessible.</p>",
173 +
				);
174 +
			}
175 +
		}
176 +
177 +
		fetchPosts();
178 +
	}, []);
179 +
180 +
	return <div dangerouslySetInnerHTML={{ __html: content }} />;
181 +
}
packages/client/src/components/PDSPost.tsx (added) +189 −0
1 +
import { useEffect, useState } from "react";
2 +
import MarkdownIt from "markdown-it";
3 +
4 +
const md = new MarkdownIt({
5 +
	html: false,
6 +
	linkify: true,
7 +
	typographer: true,
8 +
});
9 +
10 +
const DID = "did:plc:ia2zdnhjaokf5lazhxrmj6eu";
11 +
const PDS_URL = "https://polybius.social";
12 +
13 +
export default function PDSPost() {
14 +
	const [content, setContent] = useState<string>("<p>Loading...</p>");
15 +
16 +
	useEffect(() => {
17 +
		const urlParams = new URLSearchParams(window.location.search);
18 +
		const rkey = urlParams.get("rkey");
19 +
20 +
		async function fetchPost() {
21 +
			if (!rkey) {
22 +
				setContent("<p>No post specified.</p>");
23 +
				return;
24 +
			}
25 +
26 +
			try {
27 +
				// Try fetching as a document first
28 +
				const documentResponse = await fetch(
29 +
					`${PDS_URL}/xrpc/com.atproto.repo.getRecord?` +
30 +
						new URLSearchParams({
31 +
							repo: DID,
32 +
							collection: "site.standard.document",
33 +
							rkey: rkey,
34 +
						}),
35 +
				);
36 +
37 +
				if (documentResponse.ok) {
38 +
					const data = await documentResponse.json();
39 +
					const doc = data.value;
40 +
					const publishedAt = new Date(doc.publishedAt).toLocaleDateString();
41 +
42 +
					// Extract markdown content
43 +
					let contentHTML = "";
44 +
					if (doc.content && doc.content.markdown) {
45 +
						contentHTML = md.render(doc.content.markdown);
46 +
					} else if (doc.textContent) {
47 +
						contentHTML = `<p>${doc.textContent}</p>`;
48 +
					}
49 +
50 +
					setContent(`
51 +
            <article class="max-w-2xl mx-auto">
52 +
              <h1 class="text-2xl font-bold mb-4">${doc.title}</h1>
53 +
              <div class="prose prose-invert max-w-none mb-4">
54 +
                ${contentHTML}
55 +
              </div>
56 +
              <div class="flex items-center justify-between mt-4">
57 +
                <time class="text-sm text-gray-500">${publishedAt}</time>
58 +
                <button
59 +
                  id="share-btn"
60 +
                  class="text-sm text-gray-500 hover:text-gray-700 transition-colors"
61 +
                >
62 +
                  Share
63 +
                </button>
64 +
              </div>
65 +
              <div class="mt-12">
66 +
                <a class="style-link" href="/now">← Now</a>
67 +
              </div>
68 +
            </article>
69 +
          `);
70 +
71 +
					document.title = doc.title;
72 +
					return;
73 +
				}
74 +
75 +
				// Fall back to fetching as a post
76 +
				const postResponse = await fetch(
77 +
					`${PDS_URL}/xrpc/com.atproto.repo.getRecord?` +
78 +
						new URLSearchParams({
79 +
							repo: DID,
80 +
							collection: "app.bsky.feed.post",
81 +
							rkey: rkey,
82 +
						}),
83 +
				);
84 +
85 +
				if (!postResponse.ok) {
86 +
					throw new Error(`HTTP error! status: ${postResponse.status}`);
87 +
				}
88 +
89 +
				const data = await postResponse.json();
90 +
				const post = data.value;
91 +
				const createdAt = new Date(post.createdAt).toLocaleDateString();
92 +
93 +
				// Handle images
94 +
				let imagesHTML = "";
95 +
				if (
96 +
					post.embed &&
97 +
					post.embed.$type === "app.bsky.embed.images" &&
98 +
					post.embed.images
99 +
				) {
100 +
					const imageElements = post.embed.images
101 +
						.map((image: any) => {
102 +
							const blobUrl =
103 +
								`${PDS_URL}/xrpc/com.atproto.sync.getBlob?` +
104 +
								new URLSearchParams({
105 +
									did: DID,
106 +
									cid: image.image.ref.$link,
107 +
								});
108 +
109 +
							return `
110 +
              <img
111 +
                src="${blobUrl}"
112 +
                alt="${image.alt || "Image from post"}"
113 +
                class="max-w-full h-auto"
114 +
                loading="lazy"
115 +
              />
116 +
            `;
117 +
						})
118 +
						.join("");
119 +
120 +
					imagesHTML = `
121 +
            <div class="mt-3 grid gap-2 ${post.embed.images.length === 1 ? "grid-cols-1" : "grid-cols-2"}">
122 +
              ${imageElements}
123 +
            </div>
124 +
          `;
125 +
				}
126 +
127 +
				setContent(`
128 +
          <article class="max-w-2xl mx-auto">
129 +
            <p class="mb-2">${post.text}</p>
130 +
            ${imagesHTML}
131 +
            <div class="flex items-center justify-between mt-4">
132 +
              <time class="text-sm text-gray-500">${createdAt}</time>
133 +
              <button
134 +
                id="share-btn"
135 +
                class="text-sm text-gray-500 hover:text-gray-700 transition-colors"
136 +
              >
137 +
                Share
138 +
              </button>
139 +
            </div>
140 +
            <div class="mt-12">
141 +
              <a class="style-link" href="/now">← Now</a>
142 +
            </div>
143 +
          </article>
144 +
        `);
145 +
146 +
				document.title = post.text;
147 +
			} catch (err) {
148 +
				console.error("Error fetching post:", err);
149 +
				setContent(
150 +
					"<p>Error loading post. Make sure your PDS is accessible.</p>",
151 +
				);
152 +
			}
153 +
		}
154 +
155 +
		fetchPost();
156 +
	}, []);
157 +
158 +
	// Handle share button click
159 +
	useEffect(() => {
160 +
		const handleShare = () => {
161 +
			const url = window.location.href;
162 +
			navigator.clipboard
163 +
				.writeText(url)
164 +
				.then(() => {
165 +
					const btn = document.getElementById("share-btn");
166 +
					if (btn) {
167 +
						const originalText = btn.textContent;
168 +
						btn.textContent = "Copied!";
169 +
						setTimeout(() => {
170 +
							btn.textContent = originalText;
171 +
						}, 2000);
172 +
					}
173 +
				})
174 +
				.catch((err) => {
175 +
					console.error("Failed to copy URL:", err);
176 +
					alert("Failed to copy URL");
177 +
				});
178 +
		};
179 +
180 +
		// Add event listener after content is rendered
181 +
		const btn = document.getElementById("share-btn");
182 +
		if (btn) {
183 +
			btn.addEventListener("click", handleShare);
184 +
			return () => btn.removeEventListener("click", handleShare);
185 +
		}
186 +
	}, [content]);
187 +
188 +
	return <div dangerouslySetInnerHTML={{ __html: content }} />;
189 +
}
packages/client/src/pages/now/index.astro +2 −153
1 1
---
2 2
import PageLayout from "@/layouts/Base";
3 +
import NowUpdates from "@/components/NowUpdates";
3 4
4 5
const meta = {
5 6
	title: "Now",
42 43
				<span class="sr-only">RSS</span>
43 44
			</a>
44 45
  </div>
45 -
  <div id="posts-container">
46 -
    <p>Loading...</p>
47 -
  </div>
48 -
  <script type="module">
49 -
    import MarkdownIt from 'markdown-it';
50 -
51 -
    const md = new MarkdownIt({
52 -
      html: false,
53 -
      linkify: true,
54 -
      typographer: true
55 -
    });
56 -
57 -
    const DID = 'did:plc:ia2zdnhjaokf5lazhxrmj6eu';
58 -
    const PDS_URL = 'https://polybius.social';
59 -
60 -
    // Fetch both documents and posts for backward compatibility
61 -
    async function fetchPosts() {
62 -
      try {
63 -
        // Fetch new site.standard.document records
64 -
        const documentsPromise = fetch(
65 -
          `${PDS_URL}/xrpc/com.atproto.repo.listRecords?` +
66 -
          new URLSearchParams({
67 -
            repo: DID,
68 -
            collection: 'site.standard.document',
69 -
            limit: '20'
70 -
          })
71 -
        ).then(res => res.ok ? res.json() : { records: [] }).catch(() => ({ records: [] }));
72 -
73 -
        // Fetch old app.bsky.feed.post records for backward compatibility
74 -
        const postsPromise = fetch(
75 -
          `${PDS_URL}/xrpc/com.atproto.repo.listRecords?` +
76 -
          new URLSearchParams({
77 -
            repo: DID,
78 -
            collection: 'app.bsky.feed.post',
79 -
            limit: '20',
80 -
            filter: 'posts_no_replies'
81 -
          })
82 -
        ).then(res => res.ok ? res.json() : { records: [] }).catch(() => ({ records: [] }));
83 -
84 -
        const [documentsData, postsData] = await Promise.all([documentsPromise, postsPromise]);
85 -
86 -
        // Combine and normalize records
87 -
        const documents = (documentsData.records || []).map(record => ({
88 -
          ...record,
89 -
          type: 'document'
90 -
        }));
91 -
92 -
        const posts = (postsData.records || [])
93 -
          .filter(record => !record.value.reply)
94 -
          .map(record => ({
95 -
            ...record,
96 -
            type: 'post'
97 -
          }));
98 -
99 -
        // Combine all records and sort by date
100 -
        const allRecords = [...documents, ...posts].sort((a, b) => {
101 -
          const dateA = new Date(a.value.publishedAt || a.value.createdAt);
102 -
          const dateB = new Date(b.value.publishedAt || b.value.createdAt);
103 -
          return dateB - dateA; // Most recent first
104 -
        });
105 -
106 -
        if (allRecords.length === 0) {
107 -
          document.getElementById('posts-container').innerHTML = '<p>No recent updates found.</p>';
108 -
          return;
109 -
        }
110 -
111 -
        const postsHTML = allRecords.map(record => {
112 -
          const value = record.value;
113 -
          const rkey = record.uri.split('/').pop();
114 -
115 -
          // Render based on record type
116 -
          if (record.type === 'document') {
117 -
            // site.standard.document
118 -
            const publishedAt = new Date(value.publishedAt).toLocaleDateString();
119 -
120 -
            // Extract markdown content
121 -
            let contentHTML = '';
122 -
            if (value.content && value.content.markdown) {
123 -
              contentHTML = md.render(value.content.markdown);
124 -
            } else if (value.textContent) {
125 -
              contentHTML = `<p>${value.textContent}</p>`;
126 -
            }
127 -
128 -
            return `
129 -
              <div class="block border-b pb-6 mb-6 last:border-b-0">
130 -
                <article>
131 -
                  <h3 class="text-lg font-semibold mb-3">${value.title}</h3>
132 -
                  <div class="prose prose-invert max-w-none mb-3">
133 -
                    ${contentHTML}
134 -
                  </div>
135 -
                  <div class="flex items-center gap-2 text-sm text-gray-500">
136 -
                    <time>${publishedAt}</time>
137 -
                  </div>
138 -
                </article>
139 -
              </div>
140 -
            `;
141 -
          } else {
142 -
            // app.bsky.feed.post (backward compatibility)
143 -
            const createdAt = new Date(value.createdAt).toLocaleDateString();
144 -
145 -
            // Handle images
146 -
            let imagesHTML = '';
147 -
            if (value.embed && value.embed.$type === 'app.bsky.embed.images' && value.embed.images) {
148 -
              const imageElements = value.embed.images.map(image => {
149 -
                const blobUrl = `${PDS_URL}/xrpc/com.atproto.sync.getBlob?` +
150 -
                  new URLSearchParams({
151 -
                    did: DID,
152 -
                    cid: image.image.ref.$link
153 -
                  });
154 -
155 -
                return `
156 -
                  <img
157 -
                    src="${blobUrl}"
158 -
                    alt="${image.alt || 'Image from post'}"
159 -
                    class="max-w-full h-auto"
160 -
                    loading="lazy"
161 -
                  />
162 -
                `;
163 -
              }).join('');
164 -
165 -
              imagesHTML = `
166 -
                <div class="mt-3 grid gap-2 ${value.embed.images.length === 1 ? 'grid-cols-1' : 'grid-cols-2'}">
167 -
                  ${imageElements}
168 -
                </div>
169 -
              `;
170 -
            }
171 -
172 -
            return `
173 -
              <a href="/pds?rkey=${rkey}" class="block border-b pb-6 mb-6 last:border-b-0">
174 -
                <article>
175 -
                  <p class="mb-2">${value.text}</p>
176 -
                  ${imagesHTML}
177 -
                  <time class="text-sm text-gray-500 mt-2 block">${createdAt}</time>
178 -
                </article>
179 -
              </a>
180 -
            `;
181 -
          }
182 -
        }).join('');
183 -
184 -
        document.getElementById('posts-container').innerHTML = `
185 -
          <div class="space-y-4">
186 -
            <div>${postsHTML}</div>
187 -
          </div>
188 -
        `;
189 -
      } catch (err) {
190 -
        console.error('Error fetching updates:', err);
191 -
        document.getElementById('posts-container').innerHTML =
192 -
          '<p>Error loading recent updates. Make sure your PDS is accessible.</p>';
193 -
      }
194 -
    }
195 -
196 -
    fetchPosts();
197 -
  </script>
46 +
  <NowUpdates client:load />
198 47
  </div>
199 48
</PageLayout>
packages/client/src/pages/pds/index.astro +3 −110
1 1
---
2 2
import PageLayout from "@/layouts/Base";
3 +
import PDSPost from "@/components/PDSPost";
4 +
3 5
const meta = {
4 6
	title: "PDS",
5 7
	description: "Posts",
7 9
---
8 10
9 11
<PageLayout meta={meta}>
10 -
  <div id="post-container">
11 -
    <p>Loading...</p>
12 -
  </div>
13 -
14 -
  <script>
15 -
    const DID = 'did:plc:ia2zdnhjaokf5lazhxrmj6eu';
16 -
    const PDS_URL = 'https://polybius.social';
17 -
18 -
    const urlParams = new URLSearchParams(window.location.search);
19 -
    const rkey = urlParams.get('rkey');
20 -
21 -
    async function fetchPost() {
22 -
      if (!rkey) {
23 -
        document.getElementById('post-container').innerHTML = '<p>No post specified.</p>';
24 -
        return;
25 -
      }
26 -
27 -
      try {
28 -
        const response = await fetch(
29 -
          `${PDS_URL}/xrpc/com.atproto.repo.getRecord?` +
30 -
          new URLSearchParams({
31 -
            repo: DID,
32 -
            collection: 'app.bsky.feed.post',
33 -
            rkey: rkey
34 -
          })
35 -
        );
36 -
37 -
        if (!response.ok) {
38 -
          throw new Error(`HTTP error! status: ${response.status}`);
39 -
        }
40 -
41 -
        const data = await response.json();
42 -
        const post = data.value;
43 -
        const createdAt = new Date(post.createdAt).toLocaleDateString();
44 -
45 -
        // Handle images
46 -
        let imagesHTML = '';
47 -
        if (post.embed && post.embed.$type === 'app.bsky.embed.images' && post.embed.images) {
48 -
          const imageElements = post.embed.images.map(image => {
49 -
            // Construct blob URL - images are stored as blobs on the PDS
50 -
            const blobUrl = `${PDS_URL}/xrpc/com.atproto.sync.getBlob?` +
51 -
              new URLSearchParams({
52 -
                did: DID,
53 -
                cid: image.image.ref.$link
54 -
              });
55 -
56 -
            return `
57 -
              <img
58 -
                src="${blobUrl}"
59 -
                alt="${image.alt || 'Image from post'}"
60 -
                class="max-w-full h-auto"
61 -
                loading="lazy"
62 -
              />
63 -
            `;
64 -
          }).join('');
65 -
66 -
          imagesHTML = `
67 -
            <div class="mt-3 grid gap-2 ${post.embed.images.length === 1 ? 'grid-cols-1' : 'grid-cols-2'}">
68 -
              ${imageElements}
69 -
            </div>
70 -
          `;
71 -
        }
72 -
73 -
        document.getElementById('post-container').innerHTML = `
74 -
          <article class="max-w-2xl mx-auto">
75 -
            <p class="mb-2">${post.text}</p>
76 -
            ${imagesHTML}
77 -
            <div class="flex items-center justify-between mt-4">
78 -
              <time class="text-sm text-gray-500">${createdAt}</time>
79 -
              <button
80 -
                id="share-btn"
81 -
                class="text-sm text-gray-500 hover:text-gray-700 transition-colors"
82 -
                onclick="copyURL()"
83 -
              >
84 -
                Share
85 -
              </button>
86 -
            </div>
87 -
            <div class="mt-12">
88 -
              <a class="style-link" href="/now">← Now</a>
89 -
            </div>
90 -
          </article>
91 -
        `;
92 -
93 -
        document.title = post.text;
94 -
      } catch (err) {
95 -
        console.error('Error fetching post:', err);
96 -
        document.getElementById('post-container').innerHTML =
97 -
          '<p>Error loading post. Make sure your PDS is accessible.</p>';
98 -
      }
99 -
    }
100 -
101 -
    fetchPost();
102 -
103 -
    // Copy URL function
104 -
    window.copyURL = function() {
105 -
      const url = window.location.href;
106 -
      navigator.clipboard.writeText(url).then(() => {
107 -
        const btn = document.getElementById('share-btn');
108 -
        const originalText = btn.textContent;
109 -
        btn.textContent = 'Copied!';
110 -
        setTimeout(() => {
111 -
          btn.textContent = originalText;
112 -
        }, 2000);
113 -
      }).catch(err => {
114 -
        console.error('Failed to copy URL:', err);
115 -
        alert('Failed to copy URL');
116 -
      });
117 -
    };
118 -
  </script>
119 -
12 +
  <PDSPost client:load />
120 13
</PageLayout>