Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
at main 442 lines 19 kB view raw
1package components 2 3import ( 4 "fmt" 5 "strings" 6) 7 8// ActionBarProps defines properties for the action bar below feed/profile cards 9type ActionBarProps struct { 10 // Subject reference for likes 11 SubjectURI string 12 SubjectCID string 13 14 // Like state 15 IsLiked bool 16 LikeCount int 17 18 // Comment state 19 CommentCount int 20 ViewURL string // URL to view the item (for comment link) 21 ShowComments bool // Whether to show the comment button 22 23 // Share props 24 ShareURL string 25 ShareTitle string 26 ShareText string 27 28 // Ownership and actions 29 IsOwner bool 30 EditURL string 31 EditModalURL string // If set, loads edit modal via HTMX (for entities that use modal editing) 32 DeleteURL string 33 DeleteTarget string // HTMX target selector for delete (defaults to "closest .feed-card") 34 DeleteRedirect string // If set, redirect to this URL after delete instead of swapping 35 36 // Auth state 37 IsAuthenticated bool 38 39 // Moderation state 40 IsModerator bool // User has moderator role 41 CanHideRecord bool // User has hide_record permission 42 CanBlockUser bool // User has blacklist_user permission 43 IsRecordHidden bool // This record is currently hidden 44 AuthorDID string // DID of the content author (for block action) 45} 46 47// getCommentHref returns the href for the comment button. 48func (p ActionBarProps) getCommentHref() string { 49 if p.ViewURL != "" { 50 return p.ViewURL + "#comment-section" 51 } 52 return "#comment-section" 53} 54 55// getDeleteTarget returns the HTMX target for the delete button. 56func (p ActionBarProps) getDeleteTarget() string { 57 if p.DeleteTarget != "" { 58 return p.DeleteTarget 59 } 60 return "closest .feed-card" 61} 62 63// hasModActions returns true if any moderation menu items will render. 64func (p ActionBarProps) hasModActions() bool { 65 return (p.CanHideRecord && p.SubjectURI != "") || 66 (p.CanBlockUser && p.AuthorDID != "" && !p.IsOwner) 67} 68 69// hasReportAction returns true if the report menu item will render. 70func (p ActionBarProps) hasReportAction() bool { 71 return p.IsAuthenticated && !p.IsOwner 72} 73 74// ActionBar renders the action bar with Comments, Like, Share, and More menu 75// Order: [💬 Comments] [♡ Like] [↗ Share] [⋯ More] 76templ ActionBar(props ActionBarProps) { 77 <div class="action-bar" x-data="{ moreOpen: false, openUp: true }"> 78 <!-- Comments --> 79 if props.ShowComments { 80 <a 81 href={ templ.SafeURL(props.getCommentHref()) } 82 class="action-btn" 83 title="View comments" 84 > 85 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 86 <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> 87 </svg> 88 <span>{ fmt.Sprintf("%d", props.CommentCount) }</span> 89 </a> 90 } 91 <!-- Hidden indicator (visible to moderators) --> 92 if props.IsModerator && props.IsRecordHidden { 93 <span class="hidden-badge" title="This record is hidden from the public feed"> 94 <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> 95 <path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88"></path> 96 </svg> 97 Hidden 98 </span> 99 } 100 <!-- Like --> 101 if props.SubjectURI != "" && props.SubjectCID != "" { 102 @LikeButton(LikeButtonProps{ 103 SubjectURI: props.SubjectURI, 104 SubjectCID: props.SubjectCID, 105 IsLiked: props.IsLiked, 106 LikeCount: props.LikeCount, 107 IsAuthenticated: props.IsAuthenticated, 108 }) 109 } 110 <!-- Share --> 111 if props.ShareURL != "" { 112 @ActionBarShareButton(props) 113 } 114 <!-- More menu --> 115 <div class="relative z-10"> 116 <button 117 type="button" 118 @click="if (!moreOpen) { openUp = $el.getBoundingClientRect().top > window.innerHeight * 0.25 }; moreOpen = !moreOpen" 119 @click.away="moreOpen = false" 120 class="action-btn" 121 aria-label="More options" 122 > 123 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 124 <path stroke-linecap="round" stroke-linejoin="round" d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"></path> 125 </svg> 126 </button> 127 <!-- Dropdown menu --> 128 <div 129 x-show="moreOpen" 130 x-transition:enter="transition ease-out duration-100" 131 x-transition:enter-start="transform opacity-0 scale-95" 132 x-transition:enter-end="transform opacity-100 scale-100" 133 x-transition:leave="transition ease-in duration-75" 134 x-transition:leave-start="transform opacity-100 scale-100" 135 x-transition:leave-end="transform opacity-0 scale-95" 136 class="action-menu" 137 :class="openUp ? 'bottom-full mb-1' : 'top-full mt-1'" 138 x-cloak 139 > 140 if props.IsOwner { 141 if props.EditURL != "" { 142 <a href={ templ.SafeURL(props.EditURL) } class="action-menu-item"> 143 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 144 <path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125"></path> 145 </svg> 146 Edit 147 </a> 148 } 149 if props.EditModalURL != "" { 150 @ActionBarEditModal(props) 151 } 152 if props.DeleteURL != "" && props.DeleteRedirect != "" { 153 @ActionBarDeleteRedirect(props) 154 } else if props.DeleteURL != "" { 155 @ActionBarDeleteSwap(props) 156 } 157 if props.hasModActions() || props.hasReportAction() { 158 <div class="action-menu-divider"></div> 159 } 160 } 161 <!-- Moderation actions (for moderators/admins) --> 162 if props.CanHideRecord && props.SubjectURI != "" { 163 if props.IsRecordHidden { 164 <button 165 type="button" 166 hx-post="/_mod/unhide" 167 hx-vals={ fmt.Sprintf(`{"uri": "%s"}`, props.SubjectURI) } 168 hx-swap="none" 169 @click="moreOpen = false; $dispatch('notify', {message: 'Record unhidden'})" 170 class="action-menu-item" 171 > 172 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 173 <path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"></path> 174 <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"></path> 175 </svg> 176 Unhide from feed 177 </button> 178 } else { 179 <button 180 type="button" 181 hx-post="/_mod/hide" 182 hx-vals={ fmt.Sprintf(`{"uri": "%s"}`, props.SubjectURI) } 183 hx-swap="none" 184 hx-confirm="Hide this record from the public feed?" 185 @click="moreOpen = false; $dispatch('notify', {message: 'Record hidden from feed'})" 186 class="action-menu-item action-menu-item-warning" 187 > 188 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 189 <path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88"></path> 190 </svg> 191 Hide from feed 192 </button> 193 } 194 if (props.CanBlockUser && props.AuthorDID != "" && !props.IsOwner) || props.hasReportAction() { 195 <div class="action-menu-divider"></div> 196 } 197 } 198 if props.CanBlockUser && props.AuthorDID != "" && !props.IsOwner { 199 <button 200 type="button" 201 hx-post="/_mod/block" 202 hx-vals={ fmt.Sprintf(`{"did": "%s"}`, props.AuthorDID) } 203 hx-swap="none" 204 hx-confirm="Block this user? All their content will be hidden from the feed." 205 @click="moreOpen = false; $dispatch('notify', {message: 'User blocked'})" 206 class="action-menu-item action-menu-item-danger" 207 > 208 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 209 <path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 0 0 5.636 5.636m12.728 12.728A9 9 0 0 1 5.636 5.636m12.728 12.728L5.636 5.636"></path> 210 </svg> 211 Block user 212 </button> 213 if props.hasReportAction() { 214 <div class="action-menu-divider"></div> 215 } 216 } 217 <!-- Report (available to authenticated users) --> 218 if props.IsAuthenticated && !props.IsOwner { 219 <button 220 type="button" 221 @click={ fmt.Sprintf("moreOpen = false; document.getElementById('report-modal-%s').showModal()", escapeForAlpine(props.SubjectURI)) } 222 class="action-menu-item" 223 > 224 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 225 <path stroke-linecap="round" stroke-linejoin="round" d="M3 3v1.5M3 21v-6m0 0 2.77-.693a9 9 0 0 1 6.208.682l.108.054a9 9 0 0 0 6.086.71l3.114-.732a48.524 48.524 0 0 1-.005-10.499l-3.11.732a9 9 0 0 1-6.085-.711l-.108-.054a9 9 0 0 0-6.208-.682L3 4.5M3 15V4.5"></path> 226 </svg> 227 Report 228 </button> 229 } 230 </div> 231 </div> 232 <!-- Report modal (only for authenticated non-owners) --> 233 if props.IsAuthenticated && !props.IsOwner && props.SubjectURI != "" { 234 @ReportModal(ReportModalProps{ 235 ID: fmt.Sprintf("report-modal-%s", escapeForAlpine(props.SubjectURI)), 236 SubjectURI: props.SubjectURI, 237 SubjectCID: props.SubjectCID, 238 }) 239 } 240 </div> 241} 242 243// ActionBarShareButton renders the share button for the action bar 244templ ActionBarShareButton(props ActionBarProps) { 245 <button 246 type="button" 247 x-data={ fmt.Sprintf("{ copied: false, share() { const fullUrl = window.location.origin + '%s'; if (navigator.share) { navigator.share({ title: '%s', text: '%s', url: fullUrl }).catch(() => {}); } else { navigator.clipboard.writeText(fullUrl).then(() => { this.copied = true; setTimeout(() => this.copied = false, 2000); }); } } }", props.ShareURL, props.ShareTitle, props.ShareText) } 248 @click="share()" 249 class="action-btn" 250 aria-label="Share" 251 > 252 <svg x-show="!copied" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 253 <path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0 0a2.25 2.25 0 1 0 3.935 2.186 2.25 2.25 0 0 0-3.935-2.186Zm0-12.814a2.25 2.25 0 1 0 3.933-2.185 2.25 2.25 0 0 0-3.933 2.185Z"></path> 254 </svg> 255 <svg x-show="copied" x-cloak class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 256 <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"></path> 257 </svg> 258 <span x-show="copied" x-cloak>Copied!</span> 259 </button> 260} 261 262// ReportModalProps defines properties for the report modal 263type ReportModalProps struct { 264 ID string // Unique ID for the dialog 265 SubjectURI string 266 SubjectCID string 267} 268 269// ReportModal renders an inline report modal for the action bar 270templ ReportModal(props ReportModalProps) { 271 <dialog id={ props.ID } class="modal-dialog" x-data="{ reason: '', charCount: 0, submitting: false, error: '', success: false }"> 272 <div class="modal-content"> 273 <h3 class="modal-title">Report Content</h3> 274 <template x-if="!success"> 275 <form 276 @submit.prevent={ fmt.Sprintf(` 277 submitting = true; 278 error = ''; 279 const dialog = document.getElementById('%s'); 280 const body = new URLSearchParams({ 281 subject_uri: '%s', 282 subject_cid: '%s', 283 reason: reason 284 }); 285 fetch('/api/report', { 286 method: 'POST', 287 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 288 body: body 289 }) 290 .then(r => r.json().then(data => ({ok: r.ok, data}))) 291 .then(({ok, data}) => { 292 submitting = false; 293 if (ok) { 294 success = true; 295 setTimeout(() => dialog.close(), 2000); 296 } else { 297 error = data.message || 'Failed to submit report'; 298 } 299 }) 300 .catch(() => { 301 submitting = false; 302 error = 'Network error. Please try again.'; 303 }); 304 `, props.ID, escapeForJS(props.SubjectURI), escapeForJS(props.SubjectCID)) } 305 class="space-y-4" 306 > 307 <p class="text-sm text-brown-700"> 308 Please describe why you're reporting this content. Reports are reviewed by moderators. 309 </p> 310 <div> 311 <textarea 312 x-model="reason" 313 @input="charCount = reason.length" 314 name="reason" 315 placeholder="Describe the issue (optional)" 316 rows="4" 317 maxlength="500" 318 class="w-full form-textarea" 319 ></textarea> 320 <div class="flex justify-between text-xs text-brown-500 mt-1"> 321 <span>Optional, but helpful for moderators</span> 322 <span x-text="charCount + '/500'"></span> 323 </div> 324 </div> 325 <template x-if="error"> 326 <div class="bg-red-100 border border-red-300 text-red-800 px-3 py-2 rounded-lg text-sm" x-text="error"></div> 327 </template> 328 <div class="flex gap-2"> 329 <button 330 type="submit" 331 class="flex-1 btn-primary" 332 x-bind:disabled="submitting" 333 > 334 <span x-show="!submitting">Submit Report</span> 335 <span x-show="submitting">Submitting...</span> 336 </button> 337 <button 338 type="button" 339 @click="$el.closest('dialog').close()" 340 class="flex-1 btn-secondary" 341 x-bind:disabled="submitting" 342 > 343 Cancel 344 </button> 345 </div> 346 </form> 347 </template> 348 <template x-if="success"> 349 <div class="text-center py-4"> 350 <div class="text-green-600 mb-2"> 351 <svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 352 <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"></path> 353 </svg> 354 </div> 355 <p class="font-medium text-brown-900">Report Submitted</p> 356 <p class="text-sm text-brown-600 mt-1">Thank you for helping keep our community safe.</p> 357 </div> 358 </template> 359 </div> 360 </dialog> 361} 362 363// deleteRedirectScript returns an inline script for redirecting after a successful HTMX delete 364func deleteRedirectScript(url string) string { 365 return fmt.Sprintf("if(event.detail.successful) window.location.href='%s'", url) 366} 367 368// editModalAfterSwapScript returns an inline script for opening a modal after HTMX swap and reloading on save 369func editModalAfterSwapScript() string { 370 return "var d=document.querySelector('#modal-container dialog');if(d){d.showModal();var h=function(){window.location.reload()};document.body.addEventListener('refreshManage',h,{once:true});d.addEventListener('close',function(){document.body.removeEventListener('refreshManage',h)},{once:true})}" 371} 372 373// ActionBarDeleteRedirect renders a delete button that redirects after success (for view pages) 374templ ActionBarDeleteRedirect(props ActionBarProps) { 375 <button 376 type="button" 377 hx-delete={ props.DeleteURL } 378 hx-confirm="Are you sure you want to delete this?" 379 hx-swap="none" 380 hx-on--after-request={ deleteRedirectScript(props.DeleteRedirect) } 381 class="action-menu-item action-menu-item-danger" 382 > 383 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 384 <path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"></path> 385 </svg> 386 Delete 387 </button> 388} 389 390// ActionBarDeleteSwap renders a delete button that swaps out the target element (for feed cards) 391templ ActionBarDeleteSwap(props ActionBarProps) { 392 <button 393 type="button" 394 hx-delete={ props.DeleteURL } 395 hx-confirm="Are you sure you want to delete this?" 396 hx-target={ props.getDeleteTarget() } 397 hx-swap="outerHTML" 398 class="action-menu-item action-menu-item-danger" 399 > 400 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 401 <path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"></path> 402 </svg> 403 Delete 404 </button> 405} 406 407// ActionBarEditModal renders an edit button that loads an edit modal via HTMX (for entity view pages) 408templ ActionBarEditModal(props ActionBarProps) { 409 <button 410 type="button" 411 hx-get={ props.EditModalURL } 412 hx-target="#modal-container" 413 hx-swap="innerHTML" 414 class="action-menu-item" 415 @click="moreOpen = false" 416 hx-on--after-swap={ editModalAfterSwapScript() } 417 > 418 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 419 <path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125"></path> 420 </svg> 421 Edit 422 </button> 423} 424 425// escapeForAlpine escapes special characters in a string for use in Alpine.js expressions. 426// This creates a safe ID by replacing problematic characters. 427func escapeForAlpine(s string) string { 428 // Replace characters that are problematic in HTML IDs and Alpine expressions 429 s = strings.ReplaceAll(s, ":", "_") 430 s = strings.ReplaceAll(s, "/", "_") 431 s = strings.ReplaceAll(s, ".", "_") 432 return s 433} 434 435// escapeForJS escapes special characters in a string for use in JavaScript string literals. 436func escapeForJS(s string) string { 437 s = strings.ReplaceAll(s, "\\", "\\\\") 438 s = strings.ReplaceAll(s, "'", "\\'") 439 s = strings.ReplaceAll(s, "\n", "\\n") 440 s = strings.ReplaceAll(s, "\r", "\\r") 441 return s 442}