Leaflet Blog in Deno Fresh

more

-284
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 - const styles = ` 17 - .container { 18 - max-width: 740px; 19 - margin: 0 auto; 20 - } 21 - 22 - .statsBar { 23 - display: flex; 24 - align-items: center; 25 - gap: 0.5rem; 26 - } 27 - 28 - .statsBar:hover { 29 - text-decoration: underline; 30 - } 31 - 32 - .statItem { 33 - display: flex; 34 - align-items: center; 35 - gap: 0.25rem; 36 - white-space: nowrap; 37 - } 38 - 39 - .container a.link { 40 - text-decoration: underline; 41 - } 42 - 43 - .container a.link:hover { 44 - text-decoration: underline; 45 - } 46 - 47 - .icon { 48 - width: 1.25rem; 49 - height: 1.25rem; 50 - } 51 - 52 - .errorText, .loadingText { 53 - text-align: center; 54 - } 55 - 56 - .commentsTitle { 57 - margin-top: 1.5rem; 58 - font-size: 1.25rem; 59 - font-weight: bold; 60 - } 61 - 62 - .replyText { 63 - margin-top: 0.5rem; 64 - font-size: 0.875rem; 65 - } 66 - 67 - .divider { 68 - margin-top: 0.5rem; 69 - } 70 - 71 - .commentsList { 72 - margin-top: 0.5rem; 73 - display: flex; 74 - flex-direction: column; 75 - gap: 0.5rem; 76 - } 77 - 78 - .container .showMoreButton { 79 - margin-top: 0.5rem; 80 - font-size: 0.875rem; 81 - text-decoration: underline; 82 - } 83 - 84 - .container .showMoreButton:hover { 85 - text-decoration: underline; 86 - } 87 - 88 - .commentContainer { 89 - margin: 1rem 0; 90 - font-size: 0.875rem; 91 - } 92 - 93 - .commentContent { 94 - display: flex; 95 - max-width: 36rem; 96 - flex-direction: column; 97 - gap: 0.5rem; 98 - } 99 - 100 - .authorLink { 101 - display: flex; 102 - flex-direction: row; 103 - justify-content: flex-start; 104 - align-items: center; 105 - gap: 0.5rem; 106 - } 107 - 108 - .authorLink:hover { 109 - text-decoration: underline; 110 - } 111 - 112 - .avatar { 113 - height: 1rem; 114 - width: 1rem; 115 - flex-shrink: 0; 116 - border-radius: 9999px; 117 - background-color: #d1d5db; 118 - } 119 - 120 - .authorName { 121 - overflow: hidden; 122 - text-overflow: ellipsis; 123 - white-space: nowrap; 124 - display: -webkit-box; 125 - -webkit-line-clamp: 1; 126 - -webkit-box-orient: vertical; 127 - } 128 - 129 - .container a { 130 - text-decoration: none; 131 - color: inherit; 132 - } 133 - 134 - .container a:hover { 135 - text-decoration: none; 136 - } 137 - 138 - .commentContent .handle { 139 - color: #6b7280; 140 - } 141 - .repliesContainer { 142 - border-left: 2px solid #525252; 143 - padding-left: 0.5rem; 144 - } 145 - 146 - .actionsContainer { 147 - margin-top: 0.5rem; 148 - display: flex; 149 - width: 100%; 150 - max-width: 150px; 151 - flex-direction: row; 152 - align-items: center; 153 - justify-content: space-between; 154 - opacity: 0.6; 155 - } 156 - 157 - 158 - .actionsRow { 159 - display: flex; 160 - align-items: center; 161 - gap: 0.25rem; 162 - } 163 - `; 164 - 165 - return ( 166 - <div className="commentContainer"> 167 - <style>{styles}</style> 168 - <div className="commentContent"> 169 - <a 170 - className="authorLink" 171 - href={`https://bsky.app/profile/${author.did}`} 172 - target="_blank" 173 - rel="noreferrer noopener" 174 - > 175 - {author.avatar 176 - ? ( 177 - <img 178 - src={comment.post.author.avatar} 179 - alt="avatar" 180 - className={avatarClassName} 181 - /> 182 - ) 183 - : <div className={avatarClassName} />} 184 - <p className="authorName"> 185 - {author.displayName ?? author.handle}{" "} 186 - <span className="handle">@{author.handle}</span> 187 - </p> 188 - </a> 189 - <a 190 - href={`https://bsky.app/profile/${author.did}/post/${ 191 - comment.post.uri 192 - .split("/") 193 - .pop() 194 - }`} 195 - target="_blank" 196 - rel="noreferrer noopener" 197 - > 198 - <p>{(comment.post.record as AppBskyFeedPost.Record).text}</p> 199 - <Actions post={comment.post} /> 200 - </a> 201 - </div> 202 - {comment.replies && comment.replies.length > 0 && ( 203 - <div className="repliesContainer"> 204 - {comment.replies.sort(sortByLikes).map((reply) => { 205 - if (!AppBskyFeedDefs.isThreadViewPost(reply)) return null; 206 - return ( 207 - <Comment key={reply.post.uri} comment={reply} filters={filters} /> 208 - ); 209 - })} 210 - </div> 211 - )} 212 - </div> 213 - ); 214 - }; 215 - 216 - const Actions = ({ post }: { post: AppBskyFeedDefs.PostView }) => ( 217 - <div className="actionsContainer"> 218 - <div className="actionsRow"> 219 - <svg 220 - className="icon" 221 - xmlns="http://www.w3.org/2000/svg" 222 - fill="none" 223 - viewBox="0 0 24 24" 224 - strokeWidth="1.5" 225 - stroke="currentColor" 226 - > 227 - <path 228 - strokeLinecap="round" 229 - strokeLinejoin="round" 230 - 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" 231 - /> 232 - </svg> 233 - <p className="text-xs">{post.replyCount ?? 0}</p> 234 - </div> 235 - <div className="actionsRow"> 236 - <svg 237 - className="icon" 238 - xmlns="http://www.w3.org/2000/svg" 239 - fill="none" 240 - viewBox="0 0 24 24" 241 - strokeWidth="1.5" 242 - stroke="currentColor" 243 - > 244 - <path 245 - strokeLinecap="round" 246 - strokeLinejoin="round" 247 - 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" 248 - /> 249 - </svg> 250 - <p className="text-xs">{post.repostCount ?? 0}</p> 251 - </div> 252 - <div className="actionsRow"> 253 - <svg 254 - className="icon" 255 - xmlns="http://www.w3.org/2000/svg" 256 - fill="none" 257 - viewBox="0 0 24 24" 258 - strokeWidth="1.5" 259 - stroke="currentColor" 260 - > 261 - <path 262 - strokeLinecap="round" 263 - strokeLinejoin="round" 264 - 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" 265 - /> 266 - </svg> 267 - <p className="text-xs">{post.likeCount ?? 0}</p> 268 - </div> 269 - </div> 270 - ); 271 - 272 - const sortByLikes = (a: unknown, b: unknown) => { 273 - if ( 274 - !AppBskyFeedDefs.isThreadViewPost(a) || 275 - !AppBskyFeedDefs.isThreadViewPost(b) || 276 - !("post" in a) || 277 - !("post" in b) 278 - ) { 279 - return 0; 280 - } 281 - const aPost = a as AppBskyFeedDefs.ThreadViewPost; 282 - const bPost = b as AppBskyFeedDefs.ThreadViewPost; 283 - return (bPost.post.likeCount ?? 0) - (aPost.post.likeCount ?? 0); 284 - };
-75
components/bsky-comments/CommentFilters.tsx
··· 1 - import { type AppBskyFeedDefs, AppBskyFeedPost } 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: ( 53 - min: number, 54 - ) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean; 55 - MinCharacterCountFilter: ( 56 - min: number, 57 - ) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean; 58 - TextContainsFilter: ( 59 - text: string, 60 - ) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean; 61 - ExactMatchFilter: ( 62 - text: string, 63 - ) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean; 64 - NoLikes: (comment: AppBskyFeedDefs.ThreadViewPost) => boolean; 65 - NoPins: (comment: AppBskyFeedDefs.ThreadViewPost) => boolean; 66 - } = { 67 - MinLikeCountFilter, 68 - MinCharacterCountFilter, 69 - TextContainsFilter, 70 - ExactMatchFilter, 71 - NoLikes: MinLikeCountFilter(0), 72 - NoPins: ExactMatchFilter("📌"), 73 - }; 74 - 75 - 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 -1
components/typography.tsx
··· 35 35 return ( 36 36 <Tag 37 37 className={cx( 38 - "font-serif font-bold text-balance tracking-wide scroll-m-20 uppercase mt-8 [&>code]:text-[length:inherit] first:mt-0", 38 + "font-serif font-bold tracking-wide scroll-m-20 uppercase mt-8 [&>code]:text-[length:inherit] first:mt-0 break-words text-wrap", 39 39 style, 40 40 className?.toString(), 41 41 )}
-2
fresh.gen.ts
··· 8 8 import * as $index from "./routes/index.tsx"; 9 9 import * as $post_slug_ from "./routes/post/[slug].tsx"; 10 10 import * as $rss from "./routes/rss.ts"; 11 - import * as $CommentSection from "./islands/CommentSection.tsx"; 12 11 import * as $layout from "./islands/layout.tsx"; 13 12 import * as $post_list from "./islands/post-list.tsx"; 14 13 import type { Manifest } from "$fresh/server.ts"; ··· 23 22 "./routes/rss.ts": $rss, 24 23 }, 25 24 islands: { 26 - "./islands/CommentSection.tsx": $CommentSection, 27 25 "./islands/layout.tsx": $layout, 28 26 "./islands/post-list.tsx": $post_list, 29 27 },
-431
islands/CommentSection.tsx
··· 1 - import { useEffect, useState } from "preact/hooks"; 2 - import { 3 - AppBskyFeedDefs, 4 - type AppBskyFeedGetPostThread, 5 - } from "npm:@atproto/api"; 6 - import { CommentOptions } from "../components/bsky-comments/types.tsx"; 7 - import { PostSummary } from "../components/bsky-comments/PostSummary.tsx"; 8 - import { Comment } from "../components/bsky-comments/Comment.tsx"; 9 - 10 - const getAtUri = (uri: string): string => { 11 - if (!uri.startsWith("at://") && uri.includes("bsky.app/profile/")) { 12 - const match = uri.match(/profile\/([\w:.]+)\/post\/([\w]+)/); 13 - if (match) { 14 - const [, did, postId] = match; 15 - return `at://${did}/app.bsky.feed.post/${postId}`; 16 - } 17 - } 18 - return uri; 19 - }; 20 - 21 - /** 22 - * This component displays a comment section for a post. 23 - * It fetches the comments for a post and displays them in a threaded format. 24 - */ 25 - export const CommentSection = ({ 26 - uri: propUri, 27 - author, 28 - onEmpty, 29 - commentFilters, 30 - }: CommentOptions): any => { 31 - const [uri, setUri] = useState<string | null>(null); 32 - const [thread, setThread] = useState<AppBskyFeedDefs.ThreadViewPost | null>( 33 - null, 34 - ); 35 - const [error, setError] = useState<string | null>(null); 36 - const [visibleCount, setVisibleCount] = useState(5); 37 - 38 - const styles = ` 39 - .container { 40 - max-width: 740px; 41 - margin: 0 auto; 42 - } 43 - 44 - .statsBar { 45 - display: flex; 46 - align-items: center; 47 - gap: 0.5rem; 48 - } 49 - 50 - .statsBar:hover { 51 - text-decoration: underline; 52 - } 53 - 54 - .statItem { 55 - display: flex; 56 - align-items: center; 57 - gap: 0.25rem; 58 - white-space: nowrap; 59 - } 60 - 61 - .container a.link { 62 - text-decoration: underline; 63 - } 64 - 65 - .container a.link:hover { 66 - text-decoration: underline; 67 - } 68 - 69 - .icon { 70 - width: 1.25rem; 71 - height: 1.25rem; 72 - } 73 - 74 - .errorText, .loadingText { 75 - text-align: center; 76 - } 77 - 78 - .commentsTitle { 79 - margin-top: 1.5rem; 80 - font-size: 1.25rem; 81 - font-weight: bold; 82 - } 83 - 84 - .replyText { 85 - margin-top: 0.5rem; 86 - font-size: 0.875rem; 87 - } 88 - 89 - .divider { 90 - margin-top: 0.5rem; 91 - } 92 - 93 - .commentsList { 94 - margin-top: 0.5rem; 95 - display: flex; 96 - flex-direction: column; 97 - gap: 0.5rem; 98 - } 99 - 100 - .container .showMoreButton { 101 - margin-top: 0.5rem; 102 - font-size: 0.875rem; 103 - text-decoration: underline; 104 - cursor: pointer; 105 - } 106 - 107 - .container .showMoreButton:hover { 108 - text-decoration: underline; 109 - } 110 - 111 - .commentContainer { 112 - margin: 1rem 0; 113 - font-size: 0.875rem; 114 - } 115 - 116 - .commentContent { 117 - display: flex; 118 - max-width: 36rem; 119 - flex-direction: column; 120 - gap: 0.5rem; 121 - } 122 - 123 - .authorLink { 124 - display: flex; 125 - flex-direction: row; 126 - justify-content: flex-start; 127 - align-items: center; 128 - gap: 0.5rem; 129 - } 130 - 131 - .authorLink:hover { 132 - text-decoration: underline; 133 - } 134 - 135 - .avatar { 136 - height: 1rem; 137 - width: 1rem; 138 - flex-shrink: 0; 139 - border-radius: 9999px; 140 - background-color: #d1d5db; 141 - } 142 - 143 - .authorName { 144 - overflow: hidden; 145 - text-overflow: ellipsis; 146 - white-space: nowrap; 147 - display: -webkit-box; 148 - -webkit-line-clamp: 1; 149 - -webkit-box-orient: vertical; 150 - } 151 - 152 - .container a { 153 - text-decoration: none; 154 - color: inherit; 155 - } 156 - 157 - .container a:hover { 158 - text-decoration: none; 159 - } 160 - 161 - .commentContent .handle { 162 - color: #6b7280; 163 - } 164 - .repliesContainer { 165 - border-left: 2px solid #525252; 166 - padding-left: 0.5rem; 167 - } 168 - 169 - .actionsContainer { 170 - margin-top: 0.5rem; 171 - display: flex; 172 - width: 100%; 173 - max-width: 150px; 174 - flex-direction: row; 175 - align-items: center; 176 - justify-content: space-between; 177 - opacity: 0.6; 178 - } 179 - 180 - 181 - .actionsRow { 182 - display: flex; 183 - align-items: center; 184 - gap: 0.25rem; 185 - } 186 - 187 - 188 - .font-sans { font-family: var(--font-sans); } 189 - .font-serif { font-family: var(--font-serif); } 190 - .font-mono { font-family: var(--font-mono); } 191 - 192 - h1 { 193 - font-family: var(--font-serif); 194 - text-transform: uppercase; 195 - font-size: 2.25rem; 196 - } 197 - 198 - h2 { 199 - font-family: var(--font-serif); 200 - text-transform: uppercase; 201 - font-size: 1.75rem; 202 - } 203 - 204 - h3 { 205 - font-family: var(--font-serif); 206 - text-transform: uppercase; 207 - font-size: 1.5rem; 208 - } 209 - 210 - h4 { 211 - font-family: var(--font-serif); 212 - text-transform: uppercase; 213 - font-size: 1.25rem; 214 - } 215 - 216 - h5 { 217 - font-family: var(--font-serif); 218 - text-transform: uppercase; 219 - font-size: 1rem; 220 - } 221 - 222 - h6 { 223 - font-family: var(--font-serif); 224 - text-transform: uppercase; 225 - font-size: 0.875rem; 226 - } 227 - `; 228 - 229 - useEffect(() => { 230 - let isSubscribed = true; 231 - 232 - const initializeUri = async () => { 233 - if (propUri) { 234 - setUri(propUri); 235 - return; 236 - } 237 - 238 - if (author) { 239 - try { 240 - const currentUrl = window.location.href; 241 - const apiUrl = 242 - `https://public.api.bsky.app/xrpc/app.bsky.feed.searchPosts?q=*&url=${ 243 - encodeURIComponent( 244 - currentUrl, 245 - ) 246 - }&author=${author}&sort=top`; 247 - 248 - const response = await fetch(apiUrl); 249 - const data = await response.json(); 250 - 251 - if (isSubscribed) { 252 - if (data.posts && data.posts.length > 0) { 253 - const post = data.posts[0]; 254 - setUri(post.uri); 255 - } else { 256 - setError("No matching post found"); 257 - if (onEmpty) { 258 - onEmpty({ 259 - code: "not_found", 260 - message: "No matching post found", 261 - }); 262 - } 263 - } 264 - } 265 - } catch (err) { 266 - if (isSubscribed) { 267 - setError("Error fetching post"); 268 - if (onEmpty) { 269 - onEmpty({ 270 - code: "fetching_error", 271 - message: "Error fetching post", 272 - }); 273 - } 274 - } 275 - } 276 - } 277 - }; 278 - 279 - initializeUri(); 280 - 281 - return () => { 282 - isSubscribed = false; 283 - }; 284 - }, [propUri, author, onEmpty]); 285 - 286 - useEffect(() => { 287 - let isSubscribed = true; 288 - 289 - const fetchThreadData = async () => { 290 - if (!uri) return; 291 - 292 - try { 293 - const thread = await getPostThread(uri); 294 - if (isSubscribed) { 295 - setThread(thread); 296 - } 297 - } catch (err) { 298 - if (isSubscribed) { 299 - setError("Error loading comments"); 300 - if (onEmpty) { 301 - onEmpty({ 302 - code: "comment_loading_error", 303 - message: "Error loading comments", 304 - }); 305 - } 306 - } 307 - } 308 - }; 309 - 310 - fetchThreadData(); 311 - 312 - return () => { 313 - isSubscribed = false; 314 - }; 315 - }, [uri, onEmpty]); 316 - 317 - const showMore = () => { 318 - setVisibleCount((prevCount) => prevCount + 5); 319 - }; 320 - 321 - if (!uri) return null; 322 - 323 - if (error) { 324 - return ( 325 - <div className="container"> 326 - <style>{styles}</style> 327 - <p className="errorText">{error}</p> 328 - </div> 329 - ); 330 - } 331 - 332 - if (!thread) { 333 - return ( 334 - <div className="container"> 335 - <style>{styles}</style> 336 - <p className="loadingText">Loading comments...</p> 337 - </div> 338 - ); 339 - } 340 - 341 - let postUrl: string = uri; 342 - if (uri.startsWith("at://")) { 343 - const [, , did, _, rkey] = uri.split("/"); 344 - postUrl = `https://bsky.app/profile/${did}/post/${rkey}`; 345 - } 346 - 347 - if (!thread.replies || thread.replies.length === 0) { 348 - return ( 349 - <div className="container"> 350 - <style>{styles}</style> 351 - <PostSummary postUrl={postUrl} post={thread.post} /> 352 - </div> 353 - ); 354 - } 355 - 356 - // Safe sort - ensure we're working with valid objects 357 - const sortedReplies = [...thread.replies].filter((reply) => 358 - AppBskyFeedDefs.isThreadViewPost(reply) 359 - ).sort(sortByLikes); 360 - 361 - return ( 362 - <div className="container"> 363 - <style>{styles}</style> 364 - <PostSummary postUrl={postUrl} post={thread.post} /> 365 - <hr className="divider" /> 366 - <div className="commentsList"> 367 - {sortedReplies.slice(0, visibleCount).map((reply) => { 368 - if (!AppBskyFeedDefs.isThreadViewPost(reply)) return null; 369 - return ( 370 - <Comment 371 - key={reply.post.uri} 372 - comment={reply} 373 - filters={commentFilters} 374 - /> 375 - ); 376 - })} 377 - {visibleCount < sortedReplies.length && ( 378 - <button onClick={showMore} className="showMoreButton"> 379 - Show more comments 380 - </button> 381 - )} 382 - </div> 383 - </div> 384 - ); 385 - }; 386 - 387 - const getPostThread = async ( 388 - uri: string, 389 - ): Promise<AppBskyFeedDefs.ThreadViewPost> => { 390 - const atUri = getAtUri(uri); 391 - const params = new URLSearchParams({ uri: atUri }); 392 - 393 - const res = await fetch( 394 - "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?" + 395 - params.toString(), 396 - { 397 - method: "GET", 398 - headers: { 399 - Accept: "application/json", 400 - }, 401 - cache: "no-store", 402 - }, 403 - ); 404 - 405 - if (!res.ok) { 406 - console.error(await res.text()); 407 - throw new Error("Failed to fetch post thread"); 408 - } 409 - 410 - const data = (await res.json()) as AppBskyFeedGetPostThread.OutputSchema; 411 - 412 - if (!AppBskyFeedDefs.isThreadViewPost(data.thread)) { 413 - throw new Error("Could not find thread"); 414 - } 415 - 416 - return data.thread; 417 - }; 418 - 419 - const sortByLikes = (a: unknown, b: unknown) => { 420 - if ( 421 - !AppBskyFeedDefs.isThreadViewPost(a) || 422 - !AppBskyFeedDefs.isThreadViewPost(b) || 423 - !("post" in a) || 424 - !("post" in b) 425 - ) { 426 - return 0; 427 - } 428 - const aPost = a as AppBskyFeedDefs.ThreadViewPost; 429 - const bPost = b as AppBskyFeedDefs.ThreadViewPost; 430 - return (bPost.post.likeCount ?? 0) - (aPost.post.likeCount ?? 0); 431 - };
+1 -1
routes/_404.tsx
··· 12 12 <div class="flex-1 flex items-center justify-center"> 13 13 <div class="p-8 pb-20 sm:p-20 text-center"> 14 14 <Title class="font-serif-italic text-4xl sm:text-5xl lowercase mb-6"> 15 - Page not found. 15 + Page not found 16 16 </Title> 17 17 <p class="my-4">The page you were looking for doesn't exist.</p> 18 18 <a href="/" class="underline">