Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
at main 348 lines 15 kB view raw
1package components 2 3import ( 4 "arabica/internal/firehose" 5 "arabica/internal/web/bff" 6 "fmt" 7) 8 9// CommentButtonProps defines properties for the comment button 10type CommentButtonProps struct { 11 SubjectURI string // AT-URI of the record to comment on 12 SubjectCID string // CID of the record 13 CommentCount int // Number of comments on this record 14 IsAuthenticated bool // Whether the user is authenticated 15} 16 17// CommentButton renders a comment button with count that links to comments section 18templ CommentButton(props CommentButtonProps) { 19 <button 20 type="button" 21 if props.IsAuthenticated && props.SubjectURI != "" && props.SubjectCID != "" { 22 hx-get={ fmt.Sprintf("/api/comments?subject_uri=%s", props.SubjectURI) } 23 hx-target="#comment-section" 24 hx-swap="innerHTML" 25 hx-trigger="click" 26 } 27 class="comment-btn" 28 aria-label="Comments" 29 > 30 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 31 <path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z"></path> 32 </svg> 33 if props.CommentCount > 0 { 34 <span>{ fmt.Sprintf("%d", props.CommentCount) }</span> 35 } 36 </button> 37} 38 39// CommentModerationContext holds moderation state for rendering comment actions 40type CommentModerationContext struct { 41 IsModerator bool // User has moderator role 42 CanHideRecord bool // User has hide_record permission 43 CanBlockUser bool // User has blacklist_user permission 44} 45 46// CommentSectionProps defines properties for the comment section 47type CommentSectionProps struct { 48 SubjectURI string // AT-URI of the record (brew/bean/etc) 49 SubjectCID string // CID of the record 50 Comments []firehose.IndexedComment // List of comments (threaded order with depth) 51 IsAuthenticated bool // Whether the user is authenticated 52 CurrentUserDID string // DID of the current user (for delete buttons) 53 ModCtx CommentModerationContext // Moderation context for comment actions 54 ViewURL string // URL of the parent brew (for sharing comments) 55} 56 57// CommentSection renders the full comment section with list and form 58templ CommentSection(props CommentSectionProps) { 59 <div id="comment-section" class="comment-section"> 60 <!-- Section header --> 61 <div class="comment-section-header"> 62 <div class="flex items-center gap-2"> 63 <svg class="w-5 h-5 text-brown-600" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 64 <path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155"></path> 65 </svg> 66 <h3 class="text-lg font-semibold text-brown-900"> 67 Discussion 68 </h3> 69 if len(props.Comments) > 0 { 70 <span class="comment-count-badge">{ fmt.Sprintf("%d", len(props.Comments)) }</span> 71 } 72 </div> 73 </div> 74 <!-- Comment form or login prompt --> 75 if props.IsAuthenticated { 76 @CommentForm(CommentFormProps{ 77 SubjectURI: props.SubjectURI, 78 SubjectCID: props.SubjectCID, 79 }) 80 } else { 81 <div class="comment-login-prompt"> 82 <svg class="w-5 h-5 text-brown-500 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 83 <path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z"></path> 84 </svg> 85 <p class="text-sm text-brown-600"> 86 <a href="/login" class="font-semibold text-brown-800 hover:text-brown-950 underline underline-offset-2 decoration-brown-400 hover:decoration-brown-700 transition-colors">Log in</a> to join the conversation. 87 </p> 88 </div> 89 } 90 <!-- Comment list --> 91 @CommentList(CommentListProps{ 92 Comments: props.Comments, 93 CurrentUserDID: props.CurrentUserDID, 94 SubjectURI: props.SubjectURI, 95 SubjectCID: props.SubjectCID, 96 IsAuthenticated: props.IsAuthenticated, 97 ModCtx: props.ModCtx, 98 ViewURL: props.ViewURL, 99 }) 100 </div> 101} 102 103// CommentFormProps defines properties for the comment form 104type CommentFormProps struct { 105 SubjectURI string // AT-URI of the record 106 SubjectCID string // CID of the record 107} 108 109// CommentForm renders a form for creating new comments 110templ CommentForm(props CommentFormProps) { 111 <form 112 hx-post="/api/comments" 113 hx-target="#comment-section" 114 hx-swap="innerHTML" 115 class="comment-compose" 116 > 117 <input type="hidden" name="subject_uri" value={ props.SubjectURI }/> 118 <input type="hidden" name="subject_cid" value={ props.SubjectCID }/> 119 <textarea 120 name="text" 121 placeholder="Share your thoughts..." 122 class="comment-textarea" 123 rows="2" 124 maxlength="1000" 125 required 126 ></textarea> 127 <div class="flex justify-between items-center"> 128 <span class="text-xs text-brown-400 tracking-wide">1000 char limit</span> 129 <button type="submit" class="btn-primary text-sm py-1.5 px-5"> 130 Post 131 </button> 132 </div> 133 </form> 134} 135 136// CommentListProps defines properties for the comment list 137type CommentListProps struct { 138 Comments []firehose.IndexedComment // List of comments (threaded order with depth) 139 CurrentUserDID string // DID of the current user (for delete buttons) 140 SubjectURI string // AT-URI of the root subject (for reply forms) 141 SubjectCID string // CID of the root subject (for reply forms) 142 IsAuthenticated bool // Whether the user is authenticated (for reply buttons) 143 ModCtx CommentModerationContext // Moderation context for comment actions 144 ViewURL string // URL of the parent brew (for sharing comments) 145} 146 147// CommentList renders a list of comments with threading support 148templ CommentList(props CommentListProps) { 149 <div class="comment-list"> 150 if len(props.Comments) == 0 { 151 <div class="comment-empty-state"> 152 <svg class="w-10 h-10 text-brown-300 mx-auto mb-3" fill="none" stroke="currentColor" stroke-width="1" viewBox="0 0 24 24"> 153 <path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z"></path> 154 </svg> 155 <p class="text-brown-500 text-sm font-medium">No comments yet</p> 156 <p class="text-brown-400 text-xs mt-1">Be the first to share your thoughts</p> 157 </div> 158 } else { 159 for _, comment := range props.Comments { 160 @CommentItem(CommentItemProps{ 161 Comment: comment, 162 CanDelete: props.CurrentUserDID == comment.ActorDID, 163 CanReply: props.IsAuthenticated && comment.Depth < 2 && comment.CID != "", 164 IsOwner: props.CurrentUserDID == comment.ActorDID, 165 SubjectURI: props.SubjectURI, 166 SubjectCID: props.SubjectCID, 167 IsAuthenticated: props.IsAuthenticated, 168 ModCtx: props.ModCtx, 169 ViewURL: props.ViewURL, 170 }) 171 } 172 } 173 </div> 174} 175 176// CommentItemProps defines properties for a single comment 177type CommentItemProps struct { 178 Comment firehose.IndexedComment 179 CanDelete bool // Whether the current user can delete this comment 180 CanReply bool // Whether the user can reply (authenticated and depth < 2) 181 IsOwner bool // Whether the current user owns this comment 182 SubjectURI string // AT-URI of the root subject (for reply form) 183 SubjectCID string // CID of the root subject (for reply form) 184 IsAuthenticated bool // Whether the user is authenticated 185 ModCtx CommentModerationContext // Moderation context for comment actions 186 ViewURL string // URL of the parent brew (for sharing) 187} 188 189// CommentItem renders a single comment with optional threading indentation 190templ CommentItem(props CommentItemProps) { 191 <div 192 class={ "comment-item", getDepthClass(props.Comment.Depth) } 193 id={ "comment-" + props.Comment.RKey } 194 x-data="{ showReplyForm: false }" 195 > 196 if props.Comment.Depth > 0 { 197 <div class="comment-thread-line"></div> 198 } 199 <div class="comment-item-inner"> 200 <!-- Header row with user badge and reply button --> 201 <div class="flex items-center justify-between gap-2 mb-1.5"> 202 @UserBadge(UserBadgeProps{ 203 ProfileURL: "/profile/" + getCommentHandle(props.Comment), 204 AvatarURL: getCommentAvatarURL(props.Comment), 205 DisplayName: getCommentDisplayName(props.Comment), 206 Handle: getCommentHandle(props.Comment), 207 TimeAgo: bff.FormatTimeAgo(props.Comment.CreatedAt), 208 Size: "sm", 209 }) 210 if props.CanReply { 211 <button 212 type="button" 213 @click="showReplyForm = !showReplyForm" 214 class="comment-reply-btn" 215 aria-label="Reply to comment" 216 > 217 <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 218 <path stroke-linecap="round" stroke-linejoin="round" d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3"></path> 219 </svg> 220 Reply 221 </button> 222 } 223 </div> 224 <!-- Comment text --> 225 <p class="text-brown-800 whitespace-pre-wrap break-words pl-11 text-[0.9375rem] leading-relaxed">{ props.Comment.Text }</p> 226 <!-- Action bar (like, share, more menu) --> 227 <div class="pl-11 mt-1"> 228 @ActionBar(ActionBarProps{ 229 SubjectURI: buildCommentURI(props.Comment), 230 SubjectCID: props.Comment.CID, 231 IsLiked: props.Comment.IsLiked, 232 LikeCount: props.Comment.LikeCount, 233 ShareURL: getCommentShareURL(props.ViewURL, props.Comment), 234 ShareTitle: "Comment on Arabica", 235 ShareText: props.Comment.Text, 236 IsOwner: props.IsOwner, 237 DeleteURL: "/api/comments/" + props.Comment.RKey, 238 DeleteTarget: "#comment-" + props.Comment.RKey, 239 IsAuthenticated: props.IsAuthenticated, 240 IsModerator: props.ModCtx.IsModerator, 241 CanHideRecord: props.ModCtx.CanHideRecord, 242 CanBlockUser: props.ModCtx.CanBlockUser, 243 AuthorDID: props.Comment.ActorDID, 244 }) 245 </div> 246 <!-- Inline reply form (shown when Reply is clicked) --> 247 if props.CanReply { 248 <div x-show="showReplyForm" x-transition class="mt-3 pl-11"> 249 @ReplyForm(ReplyFormProps{ 250 SubjectURI: props.SubjectURI, 251 SubjectCID: props.SubjectCID, 252 ParentURI: buildCommentURI(props.Comment), 253 ParentCID: props.Comment.CID, 254 }) 255 </div> 256 } 257 </div> 258 </div> 259} 260 261// ReplyFormProps defines properties for the reply form 262type ReplyFormProps struct { 263 SubjectURI string // AT-URI of the root subject (brew/bean/etc) 264 SubjectCID string // CID of the root subject 265 ParentURI string // AT-URI of the parent comment 266 ParentCID string // CID of the parent comment 267} 268 269// ReplyForm renders a compact inline reply form 270templ ReplyForm(props ReplyFormProps) { 271 <form 272 hx-post="/api/comments" 273 hx-target="#comment-section" 274 hx-swap="innerHTML" 275 class="comment-reply-form" 276 > 277 <input type="hidden" name="subject_uri" value={ props.SubjectURI }/> 278 <input type="hidden" name="subject_cid" value={ props.SubjectCID }/> 279 <input type="hidden" name="parent_uri" value={ props.ParentURI }/> 280 <input type="hidden" name="parent_cid" value={ props.ParentCID }/> 281 <textarea 282 name="text" 283 placeholder="Write a reply..." 284 class="comment-textarea text-sm" 285 rows="2" 286 maxlength="1000" 287 required 288 ></textarea> 289 <div class="flex justify-end gap-2"> 290 <button type="button" @click="showReplyForm = false" class="btn-secondary text-xs py-1 px-3"> 291 Cancel 292 </button> 293 <button type="submit" class="btn-primary text-xs py-1 px-3"> 294 Reply 295 </button> 296 </div> 297 </form> 298} 299 300// Helper functions for comment display 301func getCommentHandle(comment firehose.IndexedComment) string { 302 if comment.Handle != "" { 303 return comment.Handle 304 } 305 return comment.ActorDID 306} 307 308func getCommentDisplayName(comment firehose.IndexedComment) string { 309 if comment.DisplayName != nil { 310 return *comment.DisplayName 311 } 312 return "" 313} 314 315func getCommentAvatarURL(comment firehose.IndexedComment) string { 316 if comment.Avatar != nil { 317 return *comment.Avatar 318 } 319 return "" 320} 321 322// getDepthClass returns the CSS class for comment depth 323func getDepthClass(depth int) string { 324 switch depth { 325 case 1: 326 return "comment-depth-1" 327 case 2: 328 return "comment-depth-2" 329 default: 330 return "" 331 } 332} 333 334// getCommentShareURL returns the share URL for a comment, linking to the comment anchor on the brew page 335func getCommentShareURL(viewURL string, comment firehose.IndexedComment) string { 336 if viewURL == "" { 337 return "" 338 } 339 return viewURL + "#comment-" + comment.RKey 340} 341 342// buildCommentURI constructs the AT-URI for a comment from its indexed data 343func buildCommentURI(comment firehose.IndexedComment) string { 344 // The subject URI contains the DID, format: at://did:plc:xxx/social.arabica.alpha.brew/rkey 345 // We need to extract the DID and build a comment URI: at://did:plc:xxx/social.arabica.alpha.comment/rkey 346 return fmt.Sprintf("at://%s/social.arabica.alpha.comment/%s", comment.ActorDID, comment.RKey) 347} 348