feat: initial footwork
f98292c8
3 file(s) · +245 −131
| 2 | 2 | import { useAuth } from "../auth/AuthStatus"; |
|
| 3 | 3 | ||
| 4 | 4 | const API_URL = import.meta.env.PUBLIC_API_URL || "https://api.stevedylan.dev"; |
|
| 5 | - | const MAX_LENGTH = 300; |
|
| 5 | + | const MAX_TITLE_LENGTH = 128; |
|
| 6 | + | const SITE_URL = "https://stevedylan.dev"; |
|
| 6 | 7 | ||
| 7 | 8 | interface PostComposerProps { |
|
| 8 | 9 | onPostCreated?: () => void; |
|
| 10 | 11 | ||
| 11 | 12 | export function PostComposer({ onPostCreated }: PostComposerProps) { |
|
| 12 | 13 | const { authenticated } = useAuth(); |
|
| 13 | - | const [text, setText] = useState(""); |
|
| 14 | + | const [title, setTitle] = useState(""); |
|
| 15 | + | const [content, setContent] = useState(""); |
|
| 14 | 16 | const [isSubmitting, setIsSubmitting] = useState(false); |
|
| 15 | 17 | const [error, setError] = useState<string | null>(null); |
|
| 16 | 18 | const [success, setSuccess] = useState(false); |
|
| 22 | 24 | const handleSubmit = async (e: React.FormEvent) => { |
|
| 23 | 25 | e.preventDefault(); |
|
| 24 | 26 | ||
| 25 | - | if (!text.trim()) { |
|
| 26 | - | setError("Post cannot be empty"); |
|
| 27 | + | if (!title.trim()) { |
|
| 28 | + | setError("Title is required"); |
|
| 29 | + | return; |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | if (title.length > MAX_TITLE_LENGTH) { |
|
| 33 | + | setError(`Title must be ${MAX_TITLE_LENGTH} characters or less`); |
|
| 27 | 34 | return; |
|
| 28 | 35 | } |
|
| 29 | 36 | ||
| 30 | - | if (text.length > MAX_LENGTH) { |
|
| 31 | - | setError(`Post must be ${MAX_LENGTH} characters or less`); |
|
| 37 | + | if (!content.trim()) { |
|
| 38 | + | setError("Content is required"); |
|
| 32 | 39 | return; |
|
| 33 | 40 | } |
|
| 34 | 41 | ||
| 43 | 50 | headers: { |
|
| 44 | 51 | "Content-Type": "application/json", |
|
| 45 | 52 | }, |
|
| 46 | - | body: JSON.stringify({ text: text.trim() }), |
|
| 53 | + | body: JSON.stringify({ |
|
| 54 | + | title: title.trim(), |
|
| 55 | + | content: content.trim(), |
|
| 56 | + | }), |
|
| 47 | 57 | }); |
|
| 48 | 58 | ||
| 49 | 59 | const data = await response.json(); |
|
| 50 | 60 | ||
| 51 | 61 | if (!response.ok) { |
|
| 52 | - | throw new Error(data.error || "Failed to create post"); |
|
| 62 | + | throw new Error(data.error || "Failed to create document"); |
|
| 53 | 63 | } |
|
| 54 | 64 | ||
| 55 | - | setText(""); |
|
| 65 | + | setTitle(""); |
|
| 66 | + | setContent(""); |
|
| 56 | 67 | setSuccess(true); |
|
| 57 | 68 | setTimeout(() => setSuccess(false), 3000); |
|
| 58 | 69 | ||
| 59 | 70 | // Notify parent to refresh posts |
|
| 60 | 71 | onPostCreated?.(); |
|
| 61 | 72 | } catch (err) { |
|
| 62 | - | setError(err instanceof Error ? err.message : "Failed to create post"); |
|
| 73 | + | setError( |
|
| 74 | + | err instanceof Error ? err.message : "Failed to create document", |
|
| 75 | + | ); |
|
| 63 | 76 | } finally { |
|
| 64 | 77 | setIsSubmitting(false); |
|
| 65 | 78 | } |
|
| 66 | 79 | }; |
|
| 67 | 80 | ||
| 68 | - | const remaining = MAX_LENGTH - text.length; |
|
| 69 | - | const isOverLimit = remaining < 0; |
|
| 81 | + | const titleRemaining = MAX_TITLE_LENGTH - title.length; |
|
| 82 | + | const isTitleOverLimit = titleRemaining < 0; |
|
| 70 | 83 | ||
| 71 | 84 | return ( |
|
| 72 | 85 | <form onSubmit={handleSubmit} className="mb-8 mt-12"> |
|
| 73 | - | <label htmlFor="post-text" className="block text-sm font-medium mb-2"> |
|
| 74 | - | New Update |
|
| 86 | + | <label |
|
| 87 | + | htmlFor="document-title" |
|
| 88 | + | className="block text-sm font-medium mb-2" |
|
| 89 | + | > |
|
| 90 | + | New Document |
|
| 75 | 91 | </label> |
|
| 76 | - | <textarea |
|
| 77 | - | id="post-text" |
|
| 78 | - | value={text} |
|
| 79 | - | onChange={(e) => setText(e.target.value)} |
|
| 80 | - | placeholder="What's happening?" |
|
| 81 | - | rows={3} |
|
| 82 | - | className="w-full bg-transparent p-3 border border-white text-white" |
|
| 83 | - | disabled={isSubmitting} |
|
| 84 | - | /> |
|
| 92 | + | ||
| 93 | + | {/* Title Field */} |
|
| 94 | + | <div className="mb-4"> |
|
| 95 | + | <label htmlFor="title" className="block text-xs font-medium mb-1"> |
|
| 96 | + | Title * |
|
| 97 | + | </label> |
|
| 98 | + | <input |
|
| 99 | + | id="title" |
|
| 100 | + | type="text" |
|
| 101 | + | value={title} |
|
| 102 | + | onChange={(e) => setTitle(e.target.value)} |
|
| 103 | + | placeholder="Document title" |
|
| 104 | + | className="w-full bg-transparent p-3 border border-white text-white" |
|
| 105 | + | disabled={isSubmitting} |
|
| 106 | + | /> |
|
| 107 | + | <span |
|
| 108 | + | className={`text-xs ${isTitleOverLimit ? "text-red-500" : titleRemaining <= 20 ? "text-yellow-500" : "text-gray-500"}`} |
|
| 109 | + | > |
|
| 110 | + | {titleRemaining} characters remaining |
|
| 111 | + | </span> |
|
| 112 | + | </div> |
|
| 113 | + | ||
| 114 | + | {/* Content Field */} |
|
| 115 | + | <div className="mb-4"> |
|
| 116 | + | <label htmlFor="content" className="block text-xs font-medium mb-1"> |
|
| 117 | + | Content (Markdown) * |
|
| 118 | + | </label> |
|
| 119 | + | <textarea |
|
| 120 | + | id="content" |
|
| 121 | + | value={content} |
|
| 122 | + | onChange={(e) => setContent(e.target.value)} |
|
| 123 | + | placeholder="Write your content in markdown..." |
|
| 124 | + | rows={10} |
|
| 125 | + | className="w-full bg-transparent p-3 border border-white text-white font-mono text-sm" |
|
| 126 | + | disabled={isSubmitting} |
|
| 127 | + | /> |
|
| 128 | + | </div> |
|
| 85 | 129 | ||
| 86 | 130 | <div className="flex items-center justify-between mt-3"> |
|
| 87 | 131 | <div className="flex items-center gap-3"> |
|
| 88 | - | <span |
|
| 89 | - | className={`text-sm ${isOverLimit ? "text-red-500" : remaining <= 20 ? "text-yellow-500" : "text-gray-500"}`} |
|
| 90 | - | > |
|
| 91 | - | {remaining} |
|
| 92 | - | </span> |
|
| 93 | - | ||
| 94 | 132 | {error && <span className="text-sm text-red-500">{error}</span>} |
|
| 95 | 133 | ||
| 96 | - | {success && <span className="text-sm">Posted successfully!</span>} |
|
| 134 | + | {success && ( |
|
| 135 | + | <span className="text-sm">Document published successfully!</span> |
|
| 136 | + | )} |
|
| 97 | 137 | </div> |
|
| 98 | 138 | ||
| 99 | 139 | <button |
|
| 100 | 140 | type="submit" |
|
| 101 | - | disabled={isSubmitting || isOverLimit || !text.trim()} |
|
| 141 | + | disabled={ |
|
| 142 | + | isSubmitting || isTitleOverLimit || !title.trim() || !content.trim() |
|
| 143 | + | } |
|
| 102 | 144 | className="px-4 py-2 border-white border text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors" |
|
| 103 | 145 | > |
|
| 104 | - | {isSubmitting ? "Posting..." : "Post"} |
|
| 146 | + | {isSubmitting ? "Publishing..." : "Publish"} |
|
| 105 | 147 | </button> |
|
| 106 | 148 | </div> |
|
| 107 | 149 | </form> |
|
| 45 | 45 | <div id="posts-container"> |
|
| 46 | 46 | <p>Loading...</p> |
|
| 47 | 47 | </div> |
|
| 48 | - | <script> |
|
| 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 | + | ||
| 49 | 57 | const DID = 'did:plc:ia2zdnhjaokf5lazhxrmj6eu'; |
|
| 50 | 58 | const PDS_URL = 'https://polybius.social'; |
|
| 51 | 59 | ||
| 52 | - | // Fetch posts directly from your PDS using the repo.listRecords endpoint |
|
| 60 | + | // Fetch both documents and posts for backward compatibility |
|
| 53 | 61 | async function fetchPosts() { |
|
| 54 | 62 | try { |
|
| 55 | - | const response = await fetch( |
|
| 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( |
|
| 56 | 75 | `${PDS_URL}/xrpc/com.atproto.repo.listRecords?` + |
|
| 57 | 76 | new URLSearchParams({ |
|
| 58 | 77 | repo: DID, |
|
| 60 | 79 | limit: '20', |
|
| 61 | 80 | filter: 'posts_no_replies' |
|
| 62 | 81 | }) |
|
| 63 | - | ); |
|
| 82 | + | ).then(res => res.ok ? res.json() : { records: [] }).catch(() => ({ records: [] })); |
|
| 64 | 83 | ||
| 65 | - | if (!response.ok) { |
|
| 66 | - | throw new Error(`HTTP error! status: ${response.status}`); |
|
| 67 | - | } |
|
| 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 | + | })); |
|
| 68 | 98 | ||
| 69 | - | const data = await response.json(); |
|
| 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 | + | }); |
|
| 70 | 105 | ||
| 71 | - | if (!data.records || data.records.length === 0) { |
|
| 72 | - | document.getElementById('posts-container').innerHTML = '<p>No recent posts found.</p>'; |
|
| 106 | + | if (allRecords.length === 0) { |
|
| 107 | + | document.getElementById('posts-container').innerHTML = '<p>No recent updates found.</p>'; |
|
| 73 | 108 | return; |
|
| 74 | 109 | } |
|
| 75 | 110 | ||
| 76 | - | // Filter out replies (posts that have a reply reference) |
|
| 77 | - | const posts = data.records.filter(record => !record.value.reply); |
|
| 111 | + | const postsHTML = allRecords.map(record => { |
|
| 112 | + | const value = record.value; |
|
| 113 | + | const rkey = record.uri.split('/').pop(); |
|
| 78 | 114 | ||
| 79 | - | const postsHTML = posts.map(record => { |
|
| 80 | - | const post = record.value; |
|
| 81 | - | const createdAt = new Date(post.createdAt).toLocaleDateString(); |
|
| 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(); |
|
| 82 | 144 | ||
| 83 | - | // Extract rkey from the record URI (format: at://did/collection/rkey) |
|
| 84 | - | const rkey = record.uri.split('/').pop(); |
|
| 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 | + | }); |
|
| 85 | 154 | ||
| 86 | - | // Handle images |
|
| 87 | - | let imagesHTML = ''; |
|
| 88 | - | if (post.embed && post.embed.$type === 'app.bsky.embed.images' && post.embed.images) { |
|
| 89 | - | const imageElements = post.embed.images.map(image => { |
|
| 90 | - | // Construct blob URL - images are stored as blobs on the PDS |
|
| 91 | - | const blobUrl = `${PDS_URL}/xrpc/com.atproto.sync.getBlob?` + |
|
| 92 | - | new URLSearchParams({ |
|
| 93 | - | did: DID, |
|
| 94 | - | cid: image.image.ref.$link |
|
| 95 | - | }); |
|
| 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(''); |
|
| 96 | 164 | ||
| 97 | - | return ` |
|
| 98 | - | <img |
|
| 99 | - | src="${blobUrl}" |
|
| 100 | - | alt="${image.alt || 'Image from post'}" |
|
| 101 | - | class="max-w-full h-auto" |
|
| 102 | - | loading="lazy" |
|
| 103 | - | /> |
|
| 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> |
|
| 104 | 169 | `; |
|
| 105 | - | }).join(''); |
|
| 170 | + | } |
|
| 106 | 171 | ||
| 107 | - | imagesHTML = ` |
|
| 108 | - | <div class="mt-3 grid gap-2 ${post.embed.images.length === 1 ? 'grid-cols-1' : 'grid-cols-2'}"> |
|
| 109 | - | ${imageElements} |
|
| 110 | - | </div> |
|
| 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> |
|
| 111 | 180 | `; |
|
| 112 | 181 | } |
|
| 113 | - | ||
| 114 | - | return ` |
|
| 115 | - | <a href="/pds?rkey=${rkey}" class="block border-b pb-6 mb-6 last:border-b-0"> |
|
| 116 | - | <article> |
|
| 117 | - | <p class="mb-2">${post.text}</p> |
|
| 118 | - | ${imagesHTML} |
|
| 119 | - | <time class="text-sm text-gray-500 mt-2 block">${createdAt}</time> |
|
| 120 | - | </article> |
|
| 121 | - | </a> |
|
| 122 | - | `; |
|
| 123 | 182 | }).join(''); |
|
| 124 | 183 | ||
| 125 | 184 | document.getElementById('posts-container').innerHTML = ` |
|
| 128 | 187 | </div> |
|
| 129 | 188 | `; |
|
| 130 | 189 | } catch (err) { |
|
| 131 | - | console.error('Error fetching posts:', err); |
|
| 190 | + | console.error('Error fetching updates:', err); |
|
| 132 | 191 | document.getElementById('posts-container').innerHTML = |
|
| 133 | - | '<p>Error loading recent posts. Make sure your PDS is accessible.</p>'; |
|
| 192 | + | '<p>Error loading recent updates. Make sure your PDS is accessible.</p>'; |
|
| 134 | 193 | } |
|
| 135 | 194 | } |
|
| 136 | 195 | ||
| 68 | 68 | } |
|
| 69 | 69 | ||
| 70 | 70 | // Parse request body |
|
| 71 | - | const body = await c.req.json<{ text: string }>(); |
|
| 72 | - | if (!body.text || body.text.trim().length === 0) { |
|
| 73 | - | return c.json({ error: "Post text is required" }, 400); |
|
| 71 | + | const body = await c.req.json<{ |
|
| 72 | + | title: string; |
|
| 73 | + | content: string; |
|
| 74 | + | }>(); |
|
| 75 | + | ||
| 76 | + | if (!body.title || body.title.trim().length === 0) { |
|
| 77 | + | return c.json({ error: "Document title is required" }, 400); |
|
| 74 | 78 | } |
|
| 75 | 79 | ||
| 76 | - | if (body.text.length > 300) { |
|
| 77 | - | return c.json({ error: "Post text must be 300 characters or less" }, 400); |
|
| 80 | + | if (body.title.length > 128) { |
|
| 81 | + | return c.json({ error: "Title must be 128 characters or less" }, 400); |
|
| 78 | 82 | } |
|
| 79 | 83 | ||
| 80 | - | // Create the post record |
|
| 84 | + | if (!body.content || body.content.trim().length === 0) { |
|
| 85 | + | return c.json({ error: "Content is required" }, 400); |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | // Create the document record using site.standard.document lexicon |
|
| 81 | 89 | const createRecordUrl = `${c.env.PDS_URL}/xrpc/com.atproto.repo.createRecord`; |
|
| 82 | 90 | ||
| 83 | - | const postRecord = { |
|
| 91 | + | // Create markdown content with $type |
|
| 92 | + | const markdownContent = { |
|
| 93 | + | $type: "site.standard.content.markdown", |
|
| 94 | + | markdown: body.content.trim(), |
|
| 95 | + | }; |
|
| 96 | + | ||
| 97 | + | // Strip markdown for textContent (basic implementation) |
|
| 98 | + | const textContent = body.content |
|
| 99 | + | .trim() |
|
| 100 | + | .replace(/#{1,6}\s/g, "") // Remove headers |
|
| 101 | + | .replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold |
|
| 102 | + | .replace(/\*([^*]+)\*/g, "$1") // Remove italic |
|
| 103 | + | .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text |
|
| 104 | + | .replace(/`([^`]+)`/g, "$1") // Remove code formatting |
|
| 105 | + | .trim(); |
|
| 106 | + | ||
| 107 | + | const documentRecord = { |
|
| 84 | 108 | repo: session.did, |
|
| 85 | - | collection: "app.bsky.feed.post", |
|
| 109 | + | collection: "site.standard.document", |
|
| 86 | 110 | record: { |
|
| 87 | - | $type: "app.bsky.feed.post", |
|
| 88 | - | text: body.text.trim(), |
|
| 89 | - | createdAt: new Date().toISOString(), |
|
| 111 | + | $type: "site.standard.document", |
|
| 112 | + | title: body.title.trim(), |
|
| 113 | + | site: "https://stevedylan.dev", |
|
| 114 | + | content: markdownContent, |
|
| 115 | + | textContent: textContent, |
|
| 116 | + | publishedAt: new Date().toISOString(), |
|
| 90 | 117 | }, |
|
| 91 | 118 | }; |
|
| 92 | 119 | ||
| 106 | 133 | Authorization: `DPoP ${session.accessToken}`, |
|
| 107 | 134 | DPoP: dpopProof, |
|
| 108 | 135 | }, |
|
| 109 | - | body: JSON.stringify(postRecord), |
|
| 136 | + | body: JSON.stringify(documentRecord), |
|
| 110 | 137 | }); |
|
| 111 | 138 | }; |
|
| 112 | 139 | ||
| 133 | 160 | ||
| 134 | 161 | if (!response.ok) { |
|
| 135 | 162 | const errorData = await response.json(); |
|
| 136 | - | console.error("Failed to create post:", errorData); |
|
| 163 | + | console.error("Failed to create document:", errorData); |
|
| 137 | 164 | return c.json( |
|
| 138 | - | { error: "Failed to create post", details: errorData }, |
|
| 165 | + | { error: "Failed to create document", details: errorData }, |
|
| 139 | 166 | response.status as 400 | 401 | 403 | 500, |
|
| 140 | 167 | ); |
|
| 141 | 168 | } |
|
| 143 | 170 | const result = (await response.json()) as { uri: string; cid: string }; |
|
| 144 | 171 | return c.json({ success: true, uri: result.uri, cid: result.cid }); |
|
| 145 | 172 | } catch (error) { |
|
| 146 | - | console.error("Error creating post:", error); |
|
| 173 | + | console.error("Error creating document:", error); |
|
| 147 | 174 | return c.json({ error: "Internal server error" }, 500); |
|
| 148 | 175 | } |
|
| 149 | 176 | }); |
|
| 150 | 177 | ||
| 151 | 178 | now.get("/rss", async (c) => { |
|
| 152 | 179 | try { |
|
| 153 | - | // Fetch posts directly from your PDS using the repo.listRecords endpoint |
|
| 180 | + | // Fetch documents directly from your PDS using the repo.listRecords endpoint |
|
| 154 | 181 | const response = await fetch( |
|
| 155 | 182 | `${PDS_URL}/xrpc/com.atproto.repo.listRecords?` + |
|
| 156 | 183 | new URLSearchParams({ |
|
| 157 | 184 | repo: DID, |
|
| 158 | - | collection: "app.bsky.feed.post", |
|
| 185 | + | collection: "site.standard.document", |
|
| 159 | 186 | limit: "50", |
|
| 160 | - | filter: "posts_no_replies", |
|
| 161 | 187 | }), |
|
| 162 | 188 | ); |
|
| 163 | 189 | ||
| 166 | 192 | } |
|
| 167 | 193 | ||
| 168 | 194 | const data = (await response.json()) as ListRecordsResponse; |
|
| 169 | - | const posts = data.records.filter((record) => !record.value.reply); |
|
| 195 | + | const documents = data.records; |
|
| 170 | 196 | ||
| 171 | 197 | // Create the feed |
|
| 172 | 198 | const feed = new Feed({ |
|
| 188 | 214 | }, |
|
| 189 | 215 | }); |
|
| 190 | 216 | ||
| 191 | - | // Add posts to the feed |
|
| 192 | - | posts.forEach((record) => { |
|
| 193 | - | const post = record.value; |
|
| 217 | + | // Add documents to the feed |
|
| 218 | + | documents.forEach((record) => { |
|
| 219 | + | const doc = record.value; |
|
| 194 | 220 | const rkey = record.uri.split("/").pop(); |
|
| 195 | 221 | ||
| 196 | - | // Build content with images if they exist |
|
| 197 | - | let content = post.text; |
|
| 198 | - | ||
| 199 | - | if ( |
|
| 200 | - | post.embed && |
|
| 201 | - | post.embed.$type === "app.bsky.embed.images" && |
|
| 202 | - | post.embed.images |
|
| 203 | - | ) { |
|
| 204 | - | const imageHTML = post.embed.images |
|
| 205 | - | .map((image) => { |
|
| 206 | - | const blobUrl = |
|
| 207 | - | `${PDS_URL}/xrpc/com.atproto.sync.getBlob?` + |
|
| 208 | - | new URLSearchParams({ |
|
| 209 | - | did: DID, |
|
| 210 | - | cid: image.image.ref.$link, |
|
| 211 | - | }); |
|
| 222 | + | // Extract content - prefer markdown content, fallback to textContent |
|
| 223 | + | let content = doc.title; |
|
| 224 | + | let description = doc.title; |
|
| 212 | 225 | ||
| 213 | - | return `<img src="${blobUrl}" alt="${image.alt || "Image from post"}" />`; |
|
| 214 | - | }) |
|
| 215 | - | .join(""); |
|
| 216 | - | ||
| 217 | - | content = `<p>${post.text}</p>${imageHTML}`; |
|
| 226 | + | if (doc.content && doc.content.markdown) { |
|
| 227 | + | content = doc.content.markdown; |
|
| 228 | + | description = doc.textContent || doc.title; |
|
| 229 | + | } else if (doc.textContent) { |
|
| 230 | + | content = doc.textContent; |
|
| 231 | + | description = doc.textContent; |
|
| 218 | 232 | } |
|
| 219 | 233 | ||
| 220 | 234 | feed.addItem({ |
|
| 221 | - | title: |
|
| 222 | - | post.text.substring(0, 100) + (post.text.length > 100 ? "..." : ""), |
|
| 235 | + | title: doc.title, |
|
| 223 | 236 | id: `https://stevedylan.dev/pds?rkey=${rkey}`, |
|
| 224 | - | link: `https://stevedylan.dev/pds?rkey=${rkey}`, |
|
| 225 | - | description: post.text, |
|
| 237 | + | link: doc.site, |
|
| 238 | + | description: description, |
|
| 226 | 239 | content: content, |
|
| 227 | - | date: new Date(post.createdAt), |
|
| 240 | + | date: new Date(doc.publishedAt), |
|
| 228 | 241 | }); |
|
| 229 | 242 | }); |
|
| 230 | 243 | ||