feat: initial footwork f98292c8
Steve · 2026-01-08 08:08 3 file(s) · +245 −131
packages/client/src/components/post/PostComposer.tsx +74 −32
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>
packages/client/src/pages/now/index.astro +110 −51
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
packages/server/src/routes/now.ts +61 −48
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