chore: added paths 3e71bef8
Steve · 2026-01-08 20:10 4 file(s) · +119 −19
packages/client/src/components/post/PostComposer.tsx +25 −0
12 12
export function PostComposer({ onPostCreated }: PostComposerProps) {
13 13
	const { authenticated } = useAuth();
14 14
	const [title, setTitle] = useState("");
15 +
	const [path, setPath] = useState("");
15 16
	const [content, setContent] = useState("");
16 17
	const [isSubmitting, setIsSubmitting] = useState(false);
17 18
	const [error, setError] = useState<string | null>(null);
52 53
				},
53 54
				body: JSON.stringify({
54 55
					title: title.trim(),
56 +
					path: path.trim() || undefined,
55 57
					content: content.trim(),
56 58
				}),
57 59
			});
63 65
			}
64 66
65 67
			setTitle("");
68 +
			setPath("");
66 69
			setContent("");
67 70
			setSuccess(true);
68 71
			setTimeout(() => setSuccess(false), 3000);
108 111
					className={`text-xs ${isTitleOverLimit ? "text-red-500" : titleRemaining <= 20 ? "text-yellow-500" : "text-gray-500"}`}
109 112
				>
110 113
					{titleRemaining} characters remaining
114 +
				</span>
115 +
			</div>
116 +
117 +
			{/* Path Field */}
118 +
			<div className="mb-4">
119 +
				<label htmlFor="path" className="block text-xs font-medium mb-1">
120 +
					Path
121 +
				</label>
122 +
				<div className="flex items-center gap-2">
123 +
					<span className="text-gray-400 text-sm">{SITE_URL}/now</span>
124 +
					<input
125 +
						id="path"
126 +
						type="text"
127 +
						value={path}
128 +
						onChange={(e) => setPath(e.target.value)}
129 +
						placeholder="/custom-path"
130 +
						className="flex-1 bg-transparent p-3 border border-white text-white"
131 +
						disabled={isSubmitting}
132 +
					/>
133 +
				</div>
134 +
				<span className="text-xs text-gray-500">
135 +
					Optional. Leave empty to use auto-generated path. Must start with /
111 136
				</span>
112 137
			</div>
113 138
packages/client/src/pages/now/[slug].astro +62 −15
28 28
let imagesHTML = "";
29 29
30 30
try {
31 -
	// Try fetching as a document first
32 -
	const documentResponse = await fetch(
33 -
		`${PDS_URL}/xrpc/com.atproto.repo.getRecord?` +
31 +
	let documentFound = false;
32 +
33 +
	// First, try to find a document with a matching custom path
34 +
	const listResponse = await fetch(
35 +
		`${PDS_URL}/xrpc/com.atproto.repo.listRecords?` +
34 36
			new URLSearchParams({
35 37
				repo: DID,
36 38
				collection: "site.standard.document",
37 -
				rkey: slug,
39 +
				limit: "100",
38 40
			}),
39 41
	);
40 42
41 -
	if (documentResponse.ok) {
42 -
		const data = await documentResponse.json();
43 -
		const doc = data.value;
44 -
		title = doc.title || "Post";
45 -
		description = doc.content?.markdown?.slice(0, 160) || description;
46 -
		publishedAt = new Date(doc.publishedAt).toLocaleDateString();
43 +
	if (listResponse.ok) {
44 +
		const listData = await listResponse.json();
45 +
		const matchingDoc = listData.records.find((record: any) => {
46 +
			// Check if document has a custom path that matches
47 +
			const docPath = record.value.path;
48 +
			if (docPath) {
49 +
				// Remove leading slash for comparison
50 +
				const normalizedPath = docPath.startsWith("/")
51 +
					? docPath.slice(1)
52 +
					: docPath;
53 +
				return normalizedPath === slug;
54 +
			}
55 +
			return false;
56 +
		});
57 +
58 +
		if (matchingDoc) {
59 +
			const doc = matchingDoc.value;
60 +
			title = doc.title || "Post";
61 +
			description = doc.content?.markdown?.slice(0, 160) || description;
62 +
			publishedAt = new Date(doc.publishedAt).toLocaleDateString();
63 +
64 +
			if (doc.content && doc.content.markdown) {
65 +
				contentHTML = md.render(doc.content.markdown);
66 +
			} else if (doc.textContent) {
67 +
				contentHTML = `<p>${doc.textContent}</p>`;
68 +
			}
69 +
			documentFound = true;
70 +
		}
71 +
	}
47 72
48 -
		if (doc.content && doc.content.markdown) {
49 -
			contentHTML = md.render(doc.content.markdown);
50 -
		} else if (doc.textContent) {
51 -
			contentHTML = `<p>${doc.textContent}</p>`;
73 +
	// If no custom path match, try fetching by rkey
74 +
	if (!documentFound) {
75 +
		const documentResponse = await fetch(
76 +
			`${PDS_URL}/xrpc/com.atproto.repo.getRecord?` +
77 +
				new URLSearchParams({
78 +
					repo: DID,
79 +
					collection: "site.standard.document",
80 +
					rkey: slug,
81 +
				}),
82 +
		);
83 +
84 +
		if (documentResponse.ok) {
85 +
			const data = await documentResponse.json();
86 +
			const doc = data.value;
87 +
			title = doc.title || "Post";
88 +
			description = doc.content?.markdown?.slice(0, 160) || description;
89 +
			publishedAt = new Date(doc.publishedAt).toLocaleDateString();
90 +
91 +
			if (doc.content && doc.content.markdown) {
92 +
				contentHTML = md.render(doc.content.markdown);
93 +
			} else if (doc.textContent) {
94 +
				contentHTML = `<p>${doc.textContent}</p>`;
95 +
			}
96 +
			documentFound = true;
52 97
		}
53 -
	} else {
98 +
	}
99 +
100 +
	if (!documentFound) {
54 101
		// Fall back to fetching as a post
55 102
		const postResponse = await fetch(
56 103
			`${PDS_URL}/xrpc/com.atproto.repo.getRecord?` +
packages/client/src/pages/now/rss.xml.ts +7 −2
12 12
		$type: string;
13 13
		title: string;
14 14
		site: string;
15 +
		path?: string;
15 16
		content?: {
16 17
			$type: string;
17 18
			markdown: string;
57 58
				const doc = record.value;
58 59
				const rkey = record.uri.split("/").pop();
59 60
61 +
				// Use custom path if available, otherwise use rkey
62 +
				const urlPath = doc.path || `/${rkey}`;
63 +
				const fullUrl = `https://stevedylan.dev/now${urlPath}`;
64 +
60 65
				let content = doc.title;
61 66
				let description = doc.title;
62 67
81 86
82 87
				return `    <item>
83 88
      <title>${escapeXml(doc.title)}</title>
84 -
      <link>https://stevedylan.dev/now/${rkey}</link>
85 -
      <guid>https://stevedylan.dev/now/${rkey}</guid>
89 +
      <link>${fullUrl}</link>
90 +
      <guid>${fullUrl}</guid>
86 91
      <description>${escapeXml(description)}</description>
87 92
      <content:encoded><![CDATA[${content}]]></content:encoded>
88 93
      <pubDate>${pubDate}</pubDate>
packages/server/src/routes/now.ts +25 −2
70 70
		// Parse request body
71 71
		const body = await c.req.json<{
72 72
			title: string;
73 +
			path?: string;
73 74
			content: string;
74 75
		}>();
75 76
85 86
			return c.json({ error: "Content is required" }, 400);
86 87
		}
87 88
89 +
		// Validate path if provided
90 +
		if (body.path) {
91 +
			if (!body.path.startsWith("/")) {
92 +
				return c.json({ error: "Path must start with /" }, 400);
93 +
			}
94 +
			// Basic validation: no spaces, no special chars except dashes and underscores
95 +
			if (!/^\/[a-zA-Z0-9\-_\/]*$/.test(body.path)) {
96 +
				return c.json(
97 +
					{
98 +
						error:
99 +
							"Path can only contain letters, numbers, dashes, underscores, and slashes",
100 +
					},
101 +
					400,
102 +
				);
103 +
			}
104 +
		}
105 +
88 106
		// Create the document record using site.standard.document lexicon
89 107
		const createRecordUrl = `${c.env.PDS_URL}/xrpc/com.atproto.repo.createRecord`;
90 108
111 129
				$type: "site.standard.document",
112 130
				title: body.title.trim(),
113 131
				site: "https://stevedylan.dev",
132 +
				...(body.path && { path: body.path.trim() }),
114 133
				content: markdownContent,
115 134
				textContent: textContent,
116 135
				publishedAt: new Date().toISOString(),
219 238
			const doc = record.value;
220 239
			const rkey = record.uri.split("/").pop();
221 240
241 +
			// Use custom path if available, otherwise use rkey
242 +
			const urlPath = doc.path || `/${rkey}`;
243 +
			const fullUrl = `https://stevedylan.dev/now${urlPath}`;
244 +
222 245
			// Extract content - prefer markdown content, fallback to textContent
223 246
			let content = doc.title;
224 247
			let description = doc.title;
233 256
234 257
			feed.addItem({
235 258
				title: doc.title,
236 -
				id: `https://stevedylan.dev/now/${rkey}`,
237 -
				link: `https://stevedylan.dev/now/${rkey}`,
259 +
				id: fullUrl,
260 +
				link: fullUrl,
238 261
				description: description,
239 262
				content: content,
240 263
				date: new Date(doc.publishedAt),