chore: added paths
3e71bef8
4 file(s) · +119 −19
| 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 | ||
| 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?` + |
| 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> |
|
| 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), |
|