Leaflet Blog in Deno Fresh

comments

Changed files
+1005 -2
components
islands
routes
post
+283
components/bsky-comments/Comment.tsx
··· 1 + import { AppBskyFeedDefs, AppBskyFeedPost } from 'npm:@atproto/api'; 2 + 3 + type CommentProps = { 4 + comment: AppBskyFeedDefs.ThreadViewPost; 5 + filters?: Array<(arg: any) => boolean>; 6 + }; 7 + 8 + export const Comment = ({ comment, filters }: CommentProps) => { 9 + const author = comment.post.author; 10 + const avatarClassName = "avatar"; 11 + 12 + if (!AppBskyFeedPost.isRecord(comment.post.record)) return null; 13 + // filter out replies that match any of the commentFilters, by ensuring they all return false 14 + if (filters && !filters.every((filter) => !filter(comment))) return null; 15 + 16 + 17 + const styles = ` 18 + .container { 19 + max-width: 740px; 20 + margin: 0 auto; 21 + } 22 + 23 + .statsBar { 24 + display: flex; 25 + align-items: center; 26 + gap: 0.5rem; 27 + } 28 + 29 + .statsBar:hover { 30 + text-decoration: underline; 31 + } 32 + 33 + .statItem { 34 + display: flex; 35 + align-items: center; 36 + gap: 0.25rem; 37 + white-space: nowrap; 38 + } 39 + 40 + .container a.link { 41 + text-decoration: underline; 42 + } 43 + 44 + .container a.link:hover { 45 + text-decoration: underline; 46 + } 47 + 48 + .icon { 49 + width: 1.25rem; 50 + height: 1.25rem; 51 + } 52 + 53 + .errorText, .loadingText { 54 + text-align: center; 55 + } 56 + 57 + .commentsTitle { 58 + margin-top: 1.5rem; 59 + font-size: 1.25rem; 60 + font-weight: bold; 61 + } 62 + 63 + .replyText { 64 + margin-top: 0.5rem; 65 + font-size: 0.875rem; 66 + } 67 + 68 + .divider { 69 + margin-top: 0.5rem; 70 + } 71 + 72 + .commentsList { 73 + margin-top: 0.5rem; 74 + display: flex; 75 + flex-direction: column; 76 + gap: 0.5rem; 77 + } 78 + 79 + .container .showMoreButton { 80 + margin-top: 0.5rem; 81 + font-size: 0.875rem; 82 + text-decoration: underline; 83 + } 84 + 85 + .container .showMoreButton:hover { 86 + text-decoration: underline; 87 + } 88 + 89 + .commentContainer { 90 + margin: 1rem 0; 91 + font-size: 0.875rem; 92 + } 93 + 94 + .commentContent { 95 + display: flex; 96 + max-width: 36rem; 97 + flex-direction: column; 98 + gap: 0.5rem; 99 + } 100 + 101 + .authorLink { 102 + display: flex; 103 + flex-direction: row; 104 + justify-content: flex-start; 105 + align-items: center; 106 + gap: 0.5rem; 107 + } 108 + 109 + .authorLink:hover { 110 + text-decoration: underline; 111 + } 112 + 113 + .avatar { 114 + height: 1rem; 115 + width: 1rem; 116 + flex-shrink: 0; 117 + border-radius: 9999px; 118 + background-color: #d1d5db; 119 + } 120 + 121 + .authorName { 122 + overflow: hidden; 123 + text-overflow: ellipsis; 124 + white-space: nowrap; 125 + display: -webkit-box; 126 + -webkit-line-clamp: 1; 127 + -webkit-box-orient: vertical; 128 + } 129 + 130 + .container a { 131 + text-decoration: none; 132 + color: inherit; 133 + } 134 + 135 + .container a:hover { 136 + text-decoration: none; 137 + } 138 + 139 + .commentContent .handle { 140 + color: #6b7280; 141 + } 142 + .repliesContainer { 143 + border-left: 2px solid #525252; 144 + padding-left: 0.5rem; 145 + } 146 + 147 + .actionsContainer { 148 + margin-top: 0.5rem; 149 + display: flex; 150 + width: 100%; 151 + max-width: 150px; 152 + flex-direction: row; 153 + align-items: center; 154 + justify-content: space-between; 155 + opacity: 0.6; 156 + } 157 + 158 + 159 + .actionsRow { 160 + display: flex; 161 + align-items: center; 162 + gap: 0.25rem; 163 + } 164 + `; 165 + 166 + return ( 167 + <div className="commentContainer"> 168 + <style>{styles}</style> 169 + <div className="commentContent"> 170 + <a 171 + className="authorLink" 172 + href={`https://bsky.app/profile/${author.did}`} 173 + target="_blank" 174 + rel="noreferrer noopener" 175 + > 176 + {author.avatar ? ( 177 + <img 178 + src={comment.post.author.avatar} 179 + alt="avatar" 180 + className={avatarClassName} 181 + /> 182 + ) : ( 183 + <div className={avatarClassName} /> 184 + )} 185 + <p className="authorName"> 186 + {author.displayName ?? author.handle}{' '} 187 + <span className="handle">@{author.handle}</span> 188 + </p> 189 + </a> 190 + <a 191 + href={`https://bsky.app/profile/${author.did}/post/${comment.post.uri 192 + .split('/') 193 + .pop()}`} 194 + target="_blank" 195 + rel="noreferrer noopener" 196 + > 197 + <p>{(comment.post.record as AppBskyFeedPost.Record).text}</p> 198 + <Actions post={comment.post} /> 199 + </a> 200 + </div> 201 + {comment.replies && comment.replies.length > 0 && ( 202 + <div className="repliesContainer"> 203 + {comment.replies.sort(sortByLikes).map((reply) => { 204 + if (!AppBskyFeedDefs.isThreadViewPost(reply)) return null; 205 + return ( 206 + <Comment key={reply.post.uri} comment={reply} filters={filters} /> 207 + ); 208 + })} 209 + </div> 210 + )} 211 + </div> 212 + ); 213 + }; 214 + 215 + const Actions = ({ post }: { post: AppBskyFeedDefs.PostView }) => ( 216 + <div className="actionsContainer"> 217 + <div className="actionsRow"> 218 + <svg 219 + className="icon" 220 + xmlns="http://www.w3.org/2000/svg" 221 + fill="none" 222 + viewBox="0 0 24 24" 223 + strokeWidth="1.5" 224 + stroke="currentColor" 225 + > 226 + <path 227 + strokeLinecap="round" 228 + strokeLinejoin="round" 229 + d="M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 0 1-.923 1.785A5.969 5.969 0 0 0 6 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337Z" 230 + /> 231 + </svg> 232 + <p className="text-xs">{post.replyCount ?? 0}</p> 233 + </div> 234 + <div className="actionsRow"> 235 + <svg 236 + className="icon" 237 + xmlns="http://www.w3.org/2000/svg" 238 + fill="none" 239 + viewBox="0 0 24 24" 240 + strokeWidth="1.5" 241 + stroke="currentColor" 242 + > 243 + <path 244 + strokeLinecap="round" 245 + strokeLinejoin="round" 246 + d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 0 0-3.7-3.7 48.678 48.678 0 0 0-7.324 0 4.006 4.006 0 0 0-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 0 0 3.7 3.7 48.656 48.656 0 0 0 7.324 0 4.006 4.006 0 0 0 3.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3-3 3" 247 + /> 248 + </svg> 249 + <p className="text-xs">{post.repostCount ?? 0}</p> 250 + </div> 251 + <div className="actionsRow"> 252 + <svg 253 + className="icon" 254 + xmlns="http://www.w3.org/2000/svg" 255 + fill="none" 256 + viewBox="0 0 24 24" 257 + strokeWidth="1.5" 258 + stroke="currentColor" 259 + > 260 + <path 261 + strokeLinecap="round" 262 + strokeLinejoin="round" 263 + d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" 264 + /> 265 + </svg> 266 + <p className="text-xs">{post.likeCount ?? 0}</p> 267 + </div> 268 + </div> 269 + ); 270 + 271 + const sortByLikes = (a: unknown, b: unknown) => { 272 + if ( 273 + !AppBskyFeedDefs.isThreadViewPost(a) || 274 + !AppBskyFeedDefs.isThreadViewPost(b) || 275 + !('post' in a) || 276 + !('post' in b) 277 + ) { 278 + return 0; 279 + } 280 + const aPost = a as AppBskyFeedDefs.ThreadViewPost; 281 + const bPost = b as AppBskyFeedDefs.ThreadViewPost; 282 + return (bPost.post.likeCount ?? 0) - (aPost.post.likeCount ?? 0); 283 + };
+67
components/bsky-comments/CommentFilters.tsx
··· 1 + import { AppBskyFeedPost, type AppBskyFeedDefs } from 'npm:@atproto/api'; 2 + 3 + const MinLikeCountFilter = ( 4 + min: number 5 + ): ((comment: AppBskyFeedDefs.ThreadViewPost) => boolean) => { 6 + return (comment: AppBskyFeedDefs.ThreadViewPost) => { 7 + return (comment.post.likeCount ?? 0) < min; 8 + }; 9 + }; 10 + 11 + const MinCharacterCountFilter = ( 12 + min: number 13 + ): ((comment: AppBskyFeedDefs.ThreadViewPost) => boolean) => { 14 + return (comment: AppBskyFeedDefs.ThreadViewPost) => { 15 + if (!AppBskyFeedPost.isRecord(comment.post.record)) { 16 + return false; 17 + } 18 + const record = comment.post.record as AppBskyFeedPost.Record; 19 + return record.text.length < min; 20 + }; 21 + }; 22 + 23 + const TextContainsFilter = ( 24 + text: string 25 + ): ((comment: AppBskyFeedDefs.ThreadViewPost) => boolean) => { 26 + return (comment: AppBskyFeedDefs.ThreadViewPost) => { 27 + if (!AppBskyFeedPost.isRecord(comment.post.record)) { 28 + return false; 29 + } 30 + const record = comment.post.record as AppBskyFeedPost.Record; 31 + return record.text.toLowerCase().includes(text.toLowerCase()); 32 + }; 33 + }; 34 + 35 + const ExactMatchFilter = ( 36 + text: string 37 + ): ((comment: AppBskyFeedDefs.ThreadViewPost) => boolean) => { 38 + return (comment: AppBskyFeedDefs.ThreadViewPost) => { 39 + if (!AppBskyFeedPost.isRecord(comment.post.record)) { 40 + return false; 41 + } 42 + const record = comment.post.record as AppBskyFeedPost.Record; 43 + return record.text.toLowerCase() === text.toLowerCase(); 44 + }; 45 + }; 46 + 47 + /* 48 + * This function allows you to filter out comments based on likes, 49 + * characters, text, pins, or exact matches. 50 + */ 51 + export const Filters: { 52 + MinLikeCountFilter: (min: number) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean; 53 + MinCharacterCountFilter: (min: number) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean; 54 + TextContainsFilter: (text: string) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean; 55 + ExactMatchFilter: (text: string) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean; 56 + NoLikes: (comment: AppBskyFeedDefs.ThreadViewPost) => boolean; 57 + NoPins: (comment: AppBskyFeedDefs.ThreadViewPost) => boolean; 58 + } = { 59 + MinLikeCountFilter, 60 + MinCharacterCountFilter, 61 + TextContainsFilter, 62 + ExactMatchFilter, 63 + NoLikes: MinLikeCountFilter(0), 64 + NoPins: ExactMatchFilter('📌'), 65 + }; 66 + 67 + export default Filters;
+232
components/bsky-comments/PostSummary.tsx
··· 1 + import { AppBskyFeedDefs } from 'npm:@atproto/api'; 2 + 3 + type PostSummaryProps = { 4 + postUrl: string; 5 + post: AppBskyFeedDefs.PostView; 6 + }; 7 + 8 + export const PostSummary = ({ postUrl, post }: PostSummaryProps) => { 9 + const styles = ` 10 + .container { 11 + max-width: 740px; 12 + margin: 0 auto; 13 + } 14 + 15 + .statsBar { 16 + display: flex; 17 + align-items: center; 18 + gap: 0.5rem; 19 + } 20 + 21 + .statsBar:hover { 22 + text-decoration: underline; 23 + } 24 + 25 + .statItem { 26 + display: flex; 27 + align-items: center; 28 + gap: 0.25rem; 29 + white-space: nowrap; 30 + } 31 + 32 + .container a.link { 33 + text-decoration: underline; 34 + } 35 + 36 + .container a.link:hover { 37 + text-decoration: underline; 38 + } 39 + 40 + .icon { 41 + width: 1.25rem; 42 + height: 1.25rem; 43 + } 44 + 45 + .errorText, .loadingText { 46 + text-align: center; 47 + } 48 + 49 + .commentsTitle { 50 + margin-top: 1.5rem; 51 + font-size: 1.25rem; 52 + font-weight: bold; 53 + } 54 + 55 + .replyText { 56 + margin-top: 0.5rem; 57 + font-size: 0.875rem; 58 + } 59 + 60 + .divider { 61 + margin-top: 0.5rem; 62 + } 63 + 64 + .commentsList { 65 + margin-top: 0.5rem; 66 + display: flex; 67 + flex-direction: column; 68 + gap: 0.5rem; 69 + } 70 + 71 + .container .showMoreButton { 72 + margin-top: 0.5rem; 73 + font-size: 0.875rem; 74 + text-decoration: underline; 75 + } 76 + 77 + .container .showMoreButton:hover { 78 + text-decoration: underline; 79 + } 80 + 81 + .commentContainer { 82 + margin: 1rem 0; 83 + font-size: 0.875rem; 84 + } 85 + 86 + .commentContent { 87 + display: flex; 88 + max-width: 36rem; 89 + flex-direction: column; 90 + gap: 0.5rem; 91 + } 92 + 93 + .authorLink { 94 + display: flex; 95 + flex-direction: row; 96 + justify-content: flex-start; 97 + align-items: center; 98 + gap: 0.5rem; 99 + } 100 + 101 + .authorLink:hover { 102 + text-decoration: underline; 103 + } 104 + 105 + .avatar { 106 + height: 1rem; 107 + width: 1rem; 108 + flex-shrink: 0; 109 + border-radius: 9999px; 110 + background-color: #d1d5db; 111 + } 112 + 113 + .authorName { 114 + overflow: hidden; 115 + text-overflow: ellipsis; 116 + white-space: nowrap; 117 + display: -webkit-box; 118 + -webkit-line-clamp: 1; 119 + -webkit-box-orient: vertical; 120 + } 121 + 122 + .container a { 123 + text-decoration: none; 124 + color: inherit; 125 + } 126 + 127 + .container a:hover { 128 + text-decoration: none; 129 + } 130 + 131 + .commentContent .handle { 132 + color: #6b7280; 133 + } 134 + .repliesContainer { 135 + border-left: 2px solid #525252; 136 + padding-left: 0.5rem; 137 + } 138 + 139 + .actionsContainer { 140 + margin-top: 0.5rem; 141 + display: flex; 142 + width: 100%; 143 + max-width: 150px; 144 + flex-direction: row; 145 + align-items: center; 146 + justify-content: space-between; 147 + opacity: 0.6; 148 + } 149 + 150 + 151 + .actionsRow { 152 + display: flex; 153 + align-items: center; 154 + gap: 0.25rem; 155 + } 156 + `; 157 + 158 + return ( 159 + <> 160 + <style>{styles}</style> 161 + <a href={postUrl} target="_blank" rel="noreferrer noopener"> 162 + <p className="statsBar"> 163 + <span className="statItem"> 164 + <svg 165 + className="icon" 166 + xmlns="http://www.w3.org/2000/svg" 167 + fill="pink" 168 + viewBox="0 0 24 24" 169 + strokeWidth="1.5" 170 + stroke="pink" 171 + flood-color="pink" 172 + > 173 + <path 174 + strokeLinecap="round" 175 + strokeLinejoin="round" 176 + d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" 177 + /> 178 + </svg> 179 + <span>{post.likeCount ?? 0} likes</span> 180 + </span> 181 + <span className="statItem"> 182 + <svg 183 + className="icon" 184 + xmlns="http://www.w3.org/2000/svg" 185 + fill="none" 186 + viewBox="0 0 24 24" 187 + strokeWidth="1.5" 188 + stroke="green" 189 + > 190 + <path 191 + strokeLinecap="round" 192 + strokeLinejoin="round" 193 + d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 0 0-3.7-3.7 48.678 48.678 0 0 0-7.324 0 4.006 4.006 0 0 0-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 0 0 3.7 3.7 48.656 48.656 0 0 0 7.324 0 4.006 4.006 0 0 0 3.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3-3 3" 194 + /> 195 + </svg> 196 + <span>{post.repostCount ?? 0} reposts</span> 197 + </span> 198 + <span className="statItem"> 199 + <svg 200 + className="icon" 201 + xmlns="http://www.w3.org/2000/svg" 202 + fill="#7FBADC" 203 + viewBox="0 0 24 24" 204 + strokeWidth="1.5" 205 + stroke="#7FBADC" 206 + > 207 + <path 208 + strokeLinecap="round" 209 + strokeLinejoin="round" 210 + d="M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 0 1-.923 1.785A5.969 5.969 0 0 0 6 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337Z" 211 + /> 212 + </svg> 213 + <span>{post.replyCount ?? 0} replies</span> 214 + </span> 215 + </p> 216 + </a> 217 + <h2 className="commentsTitle">Comments</h2> 218 + <p className="replyText"> 219 + Join the conversation by{' '} 220 + <a 221 + className="link" 222 + href={postUrl} 223 + target="_blank" 224 + rel="noreferrer noopener" 225 + > 226 + replying on Bluesky 227 + </a> 228 + . 229 + </p> 230 + </> 231 + ); 232 + };
+11
components/bsky-comments/types.tsx
··· 1 + export interface CommentEmptyDetails { 2 + code: string; 3 + message: string; 4 + } 5 + 6 + export interface CommentOptions { 7 + uri?: string; 8 + author?: string; 9 + commentFilters?: Array<(arg: any) => boolean>; 10 + onEmpty?: (details: CommentEmptyDetails) => void; 11 + }
+1 -2
components/post-info.tsx
··· 32 32 )} 33 33 {createdAt && ( 34 34 <> 35 - <time dateTime={createdAt}>{date(new Date(createdAt))}</time>{" "} 36 - &middot;{" "} 35 + <time dateTime={createdAt}>{date(new Date(createdAt))}</time> 37 36 </> 38 37 )} 39 38 {children}
+2
fresh.gen.ts
··· 7 7 import * as $index from "./routes/index.tsx"; 8 8 import * as $post_slug_ from "./routes/post/[slug].tsx"; 9 9 import * as $rss from "./routes/rss.ts"; 10 + import * as $CommentSection from "./islands/CommentSection.tsx"; 10 11 import * as $post_list from "./islands/post-list.tsx"; 11 12 import type { Manifest } from "$fresh/server.ts"; 12 13 ··· 19 20 "./routes/rss.ts": $rss, 20 21 }, 21 22 islands: { 23 + "./islands/CommentSection.tsx": $CommentSection, 22 24 "./islands/post-list.tsx": $post_list, 23 25 }, 24 26 baseUrl: import.meta.url,
+405
islands/CommentSection.tsx
··· 1 + import { useState, useEffect } from 'preact/hooks'; 2 + import { AppBskyFeedDefs, type AppBskyFeedGetPostThread } from 'npm:@atproto/api'; 3 + import { CommentOptions } from '../components/bsky-comments/types.tsx'; 4 + import { PostSummary } from '../components/bsky-comments/PostSummary.tsx'; 5 + import { Comment } from '../components/bsky-comments/Comment.tsx'; 6 + 7 + const getAtUri = (uri: string): string => { 8 + if (!uri.startsWith('at://') && uri.includes('bsky.app/profile/')) { 9 + const match = uri.match(/profile\/([\w:.]+)\/post\/([\w]+)/); 10 + if (match) { 11 + const [, did, postId] = match; 12 + return `at://${did}/app.bsky.feed.post/${postId}`; 13 + } 14 + } 15 + return uri; 16 + }; 17 + 18 + /** 19 + * This component displays a comment section for a post. 20 + * It fetches the comments for a post and displays them in a threaded format. 21 + */ 22 + export const CommentSection = ({ 23 + uri: propUri, 24 + author, 25 + onEmpty, 26 + commentFilters, 27 + }: CommentOptions): any => { 28 + const [uri, setUri] = useState<string | null>(null); 29 + const [thread, setThread] = useState<AppBskyFeedDefs.ThreadViewPost | null>(null); 30 + const [error, setError] = useState<string | null>(null); 31 + const [visibleCount, setVisibleCount] = useState(5); 32 + 33 + const styles = ` 34 + .container { 35 + max-width: 740px; 36 + margin: 0 auto; 37 + } 38 + 39 + .statsBar { 40 + display: flex; 41 + align-items: center; 42 + gap: 0.5rem; 43 + } 44 + 45 + .statsBar:hover { 46 + text-decoration: underline; 47 + } 48 + 49 + .statItem { 50 + display: flex; 51 + align-items: center; 52 + gap: 0.25rem; 53 + white-space: nowrap; 54 + } 55 + 56 + .container a.link { 57 + text-decoration: underline; 58 + } 59 + 60 + .container a.link:hover { 61 + text-decoration: underline; 62 + } 63 + 64 + .icon { 65 + width: 1.25rem; 66 + height: 1.25rem; 67 + } 68 + 69 + .errorText, .loadingText { 70 + text-align: center; 71 + } 72 + 73 + .commentsTitle { 74 + margin-top: 1.5rem; 75 + font-size: 1.25rem; 76 + font-weight: bold; 77 + } 78 + 79 + .replyText { 80 + margin-top: 0.5rem; 81 + font-size: 0.875rem; 82 + } 83 + 84 + .divider { 85 + margin-top: 0.5rem; 86 + } 87 + 88 + .commentsList { 89 + margin-top: 0.5rem; 90 + display: flex; 91 + flex-direction: column; 92 + gap: 0.5rem; 93 + } 94 + 95 + .container .showMoreButton { 96 + margin-top: 0.5rem; 97 + font-size: 0.875rem; 98 + text-decoration: underline; 99 + cursor: pointer; 100 + } 101 + 102 + .container .showMoreButton:hover { 103 + text-decoration: underline; 104 + } 105 + 106 + .commentContainer { 107 + margin: 1rem 0; 108 + font-size: 0.875rem; 109 + } 110 + 111 + .commentContent { 112 + display: flex; 113 + max-width: 36rem; 114 + flex-direction: column; 115 + gap: 0.5rem; 116 + } 117 + 118 + .authorLink { 119 + display: flex; 120 + flex-direction: row; 121 + justify-content: flex-start; 122 + align-items: center; 123 + gap: 0.5rem; 124 + } 125 + 126 + .authorLink:hover { 127 + text-decoration: underline; 128 + } 129 + 130 + .avatar { 131 + height: 1rem; 132 + width: 1rem; 133 + flex-shrink: 0; 134 + border-radius: 9999px; 135 + background-color: #d1d5db; 136 + } 137 + 138 + .authorName { 139 + overflow: hidden; 140 + text-overflow: ellipsis; 141 + white-space: nowrap; 142 + display: -webkit-box; 143 + -webkit-line-clamp: 1; 144 + -webkit-box-orient: vertical; 145 + } 146 + 147 + .container a { 148 + text-decoration: none; 149 + color: inherit; 150 + } 151 + 152 + .container a:hover { 153 + text-decoration: none; 154 + } 155 + 156 + .commentContent .handle { 157 + color: #6b7280; 158 + } 159 + .repliesContainer { 160 + border-left: 2px solid #525252; 161 + padding-left: 0.5rem; 162 + } 163 + 164 + .actionsContainer { 165 + margin-top: 0.5rem; 166 + display: flex; 167 + width: 100%; 168 + max-width: 150px; 169 + flex-direction: row; 170 + align-items: center; 171 + justify-content: space-between; 172 + opacity: 0.6; 173 + } 174 + 175 + 176 + .actionsRow { 177 + display: flex; 178 + align-items: center; 179 + gap: 0.25rem; 180 + } 181 + 182 + 183 + .font-sans { font-family: var(--font-sans); } 184 + .font-serif { font-family: var(--font-serif); } 185 + .font-mono { font-family: var(--font-mono); } 186 + 187 + h1 { 188 + font-family: var(--font-serif); 189 + text-transform: uppercase; 190 + font-size: 2.25rem; 191 + } 192 + 193 + h2 { 194 + font-family: var(--font-serif); 195 + text-transform: uppercase; 196 + font-size: 1.75rem; 197 + } 198 + 199 + h3 { 200 + font-family: var(--font-serif); 201 + text-transform: uppercase; 202 + font-size: 1.5rem; 203 + } 204 + 205 + h4 { 206 + font-family: var(--font-serif); 207 + text-transform: uppercase; 208 + font-size: 1.25rem; 209 + } 210 + 211 + h5 { 212 + font-family: var(--font-serif); 213 + text-transform: uppercase; 214 + font-size: 1rem; 215 + } 216 + 217 + h6 { 218 + font-family: var(--font-serif); 219 + text-transform: uppercase; 220 + font-size: 0.875rem; 221 + } 222 + `; 223 + 224 + useEffect(() => { 225 + let isSubscribed = true; 226 + 227 + const initializeUri = async () => { 228 + if (propUri) { 229 + setUri(propUri); 230 + return; 231 + } 232 + 233 + if (author) { 234 + try { 235 + const currentUrl = window.location.href; 236 + const apiUrl = `https://public.api.bsky.app/xrpc/app.bsky.feed.searchPosts?q=*&url=${encodeURIComponent( 237 + currentUrl 238 + )}&author=${author}&sort=top`; 239 + 240 + const response = await fetch(apiUrl); 241 + const data = await response.json(); 242 + 243 + if (isSubscribed) { 244 + if (data.posts && data.posts.length > 0) { 245 + const post = data.posts[0]; 246 + setUri(post.uri); 247 + } else { 248 + setError('No matching post found'); 249 + if (onEmpty) { 250 + onEmpty({ code: 'not_found', message: 'No matching post found' }); 251 + } 252 + } 253 + } 254 + } catch (err) { 255 + if (isSubscribed) { 256 + setError('Error fetching post'); 257 + if (onEmpty) { 258 + onEmpty({ code: 'fetching_error', message: 'Error fetching post' }); 259 + } 260 + } 261 + } 262 + } 263 + }; 264 + 265 + initializeUri(); 266 + 267 + return () => { 268 + isSubscribed = false; 269 + }; 270 + }, [propUri, author, onEmpty]); 271 + 272 + useEffect(() => { 273 + let isSubscribed = true; 274 + 275 + const fetchThreadData = async () => { 276 + if (!uri) return; 277 + 278 + try { 279 + const thread = await getPostThread(uri); 280 + if (isSubscribed) { 281 + setThread(thread); 282 + } 283 + } catch (err) { 284 + if (isSubscribed) { 285 + setError('Error loading comments'); 286 + if (onEmpty) { 287 + onEmpty({ 288 + code: 'comment_loading_error', 289 + message: 'Error loading comments', 290 + }); 291 + } 292 + } 293 + } 294 + }; 295 + 296 + fetchThreadData(); 297 + 298 + return () => { 299 + isSubscribed = false; 300 + }; 301 + }, [uri, onEmpty]); 302 + 303 + const showMore = () => { 304 + setVisibleCount((prevCount) => prevCount + 5); 305 + }; 306 + 307 + if (!uri) return null; 308 + 309 + if (error) { 310 + return <div className="container"><style>{styles}</style><p className="errorText">{error}</p></div>; 311 + } 312 + 313 + if (!thread) { 314 + return <div className="container"><style>{styles}</style><p className="loadingText">Loading comments...</p></div>; 315 + } 316 + 317 + let postUrl: string = uri; 318 + if (uri.startsWith('at://')) { 319 + const [, , did, _, rkey] = uri.split('/'); 320 + postUrl = `https://bsky.app/profile/${did}/post/${rkey}`; 321 + } 322 + 323 + if (!thread.replies || thread.replies.length === 0) { 324 + return ( 325 + <div className="container"> 326 + <style>{styles}</style> 327 + <PostSummary postUrl={postUrl} post={thread.post} /> 328 + </div> 329 + ); 330 + } 331 + 332 + // Safe sort - ensure we're working with valid objects 333 + const sortedReplies = [...thread.replies].filter(reply => 334 + AppBskyFeedDefs.isThreadViewPost(reply) 335 + ).sort(sortByLikes); 336 + 337 + return ( 338 + <div className="container"> 339 + <style>{styles}</style> 340 + <PostSummary postUrl={postUrl} post={thread.post} /> 341 + <hr className="divider" /> 342 + <div className="commentsList"> 343 + {sortedReplies.slice(0, visibleCount).map((reply) => { 344 + if (!AppBskyFeedDefs.isThreadViewPost(reply)) return null; 345 + return ( 346 + <Comment 347 + key={reply.post.uri} 348 + comment={reply} 349 + filters={commentFilters} 350 + /> 351 + ); 352 + })} 353 + {visibleCount < sortedReplies.length && ( 354 + <button onClick={showMore} className="showMoreButton"> 355 + Show more comments 356 + </button> 357 + )} 358 + </div> 359 + </div> 360 + ); 361 + }; 362 + 363 + const getPostThread = async (uri: string): Promise<AppBskyFeedDefs.ThreadViewPost> => { 364 + const atUri = getAtUri(uri); 365 + const params = new URLSearchParams({ uri: atUri }); 366 + 367 + const res = await fetch( 368 + 'https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?' + 369 + params.toString(), 370 + { 371 + method: 'GET', 372 + headers: { 373 + Accept: 'application/json', 374 + }, 375 + cache: 'no-store', 376 + } 377 + ); 378 + 379 + if (!res.ok) { 380 + console.error(await res.text()); 381 + throw new Error('Failed to fetch post thread'); 382 + } 383 + 384 + const data = (await res.json()) as AppBskyFeedGetPostThread.OutputSchema; 385 + 386 + if (!AppBskyFeedDefs.isThreadViewPost(data.thread)) { 387 + throw new Error('Could not find thread'); 388 + } 389 + 390 + return data.thread; 391 + }; 392 + 393 + const sortByLikes = (a: unknown, b: unknown) => { 394 + if ( 395 + !AppBskyFeedDefs.isThreadViewPost(a) || 396 + !AppBskyFeedDefs.isThreadViewPost(b) || 397 + !('post' in a) || 398 + !('post' in b) 399 + ) { 400 + return 0; 401 + } 402 + const aPost = a as AppBskyFeedDefs.ThreadViewPost; 403 + const bPost = b as AppBskyFeedDefs.ThreadViewPost; 404 + return (bPost.post.likeCount ?? 0) - (aPost.post.likeCount ?? 0); 405 + };
+4
routes/post/[slug].tsx
··· 7 7 import { Title } from "../../components/typography.tsx"; 8 8 import { getPost } from "../../lib/api.ts"; 9 9 import { Head } from "$fresh/runtime.ts"; 10 + import { CommentSection } from "../../islands/CommentSection.tsx"; 10 11 11 12 interface Post { 12 13 uri: string; ··· 145 146 /> 146 147 </div> 147 148 </article> 149 + <CommentSection 150 + author="knotbin.xyz" 151 + /> 148 152 </main> 149 153 <Footer /> 150 154 </div>