Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
17
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: clean up dead code

+34 -403
-1
internal/models/models.go
··· 24 24 MaxBurrTypeLength = 50 25 25 MaxBrewerTypeLength = 100 26 26 MaxCommentLength = 1000 27 - MaxCommentGraphemes = 300 28 27 ) 29 28 30 29 // Visibility controls who can see a piece of profile data.
-27
internal/web/bff/helpers.go
··· 51 51 return fmt.Sprintf("%dm %ds", minutes, remaining) 52 52 } 53 53 54 - // FormatRating formats a rating as "X/10". 55 - // Returns "N/A" if rating is 0. 56 - func FormatRating(rating int) string { 57 - if rating == 0 { 58 - return "N/A" 59 - } 60 - return fmt.Sprintf("%d/10", rating) 61 - } 62 - 63 54 // FormatBeanRating formats a bean's optional rating as "X/10". 64 55 // Returns empty string if rating is nil (unrated). 65 56 func FormatBeanRating(rating *int) string { ··· 108 99 // HasTemp returns true if temperature is greater than zero 109 100 func HasTemp(temp float64) bool { 110 101 return temp > 0 111 - } 112 - 113 - // HasValue returns true if the int value is greater than zero 114 - func HasValue(val int) bool { 115 - return val > 0 116 102 } 117 103 118 104 // SafeAvatarURL validates and sanitizes avatar URLs to prevent XSS and other attacks. ··· 187 173 } 188 174 189 175 return websiteURL 190 - } 191 - 192 - // EscapeJS escapes a string for safe use in JavaScript string literals. 193 - // Handles newlines, quotes, backslashes, and other special characters. 194 - func EscapeJS(s string) string { 195 - // Replace special characters that would break JavaScript strings 196 - s = strings.ReplaceAll(s, "\\", "\\\\") // Must be first 197 - s = strings.ReplaceAll(s, "'", "\\'") 198 - s = strings.ReplaceAll(s, "\"", "\\\"") 199 - s = strings.ReplaceAll(s, "\n", "\\n") 200 - s = strings.ReplaceAll(s, "\r", "\\r") 201 - s = strings.ReplaceAll(s, "\t", "\\t") 202 - return s 203 176 } 204 177 205 178 // FormatISO returns the time formatted as RFC3339 UTC, suitable for HTML datetime attributes.
-51
internal/web/bff/helpers_test.go
··· 55 55 } 56 56 } 57 57 58 - func TestFormatRating(t *testing.T) { 59 - tests := []struct { 60 - name string 61 - rating int 62 - expected string 63 - }{ 64 - {"zero returns N/A", 0, "N/A"}, 65 - {"rating 1", 1, "1/10"}, 66 - {"rating 5", 5, "5/10"}, 67 - {"rating 10", 10, "10/10"}, 68 - } 69 - 70 - for _, tt := range tests { 71 - t.Run(tt.name, func(t *testing.T) { 72 - got := FormatRating(tt.rating) 73 - assert.Equal(t, tt.expected, got) 74 - }) 75 - } 76 - } 77 - 78 58 func TestPoursToJSON(t *testing.T) { 79 59 tests := []struct { 80 60 name string ··· 131 111 assert.True(t, HasTemp(93.5)) 132 112 } 133 113 134 - func TestHasValue(t *testing.T) { 135 - assert.False(t, HasValue(0)) 136 - assert.False(t, HasValue(-1)) 137 - assert.True(t, HasValue(1)) 138 - assert.True(t, HasValue(250)) 139 - } 140 - 141 114 func TestSafeAvatarURL(t *testing.T) { 142 115 tests := []struct { 143 116 name string ··· 183 156 for _, tt := range tests { 184 157 t.Run(tt.name, func(t *testing.T) { 185 158 assert.Equal(t, tt.expected, SafeWebsiteURL(tt.input)) 186 - }) 187 - } 188 - } 189 - 190 - func TestEscapeJS(t *testing.T) { 191 - tests := []struct { 192 - name string 193 - input string 194 - expected string 195 - }{ 196 - {"empty string", "", ""}, 197 - {"no special chars", "hello world", "hello world"}, 198 - {"single quotes", "it's a test", "it\\'s a test"}, 199 - {"double quotes", `say "hello"`, `say \"hello\"`}, 200 - {"newlines", "line1\nline2", "line1\\nline2"}, 201 - {"carriage return", "line1\rline2", "line1\\rline2"}, 202 - {"tabs", "col1\tcol2", "col1\\tcol2"}, 203 - {"backslash", `path\to\file`, `path\\to\\file`}, 204 - {"mixed", "it's a \"test\"\nwith\\stuff", "it\\'s a \\\"test\\\"\\nwith\\\\stuff"}, 205 - } 206 - 207 - for _, tt := range tests { 208 - t.Run(tt.name, func(t *testing.T) { 209 - assert.Equal(t, tt.expected, EscapeJS(tt.input)) 210 159 }) 211 160 } 212 161 }
-102
internal/web/components/dialog_modals.templ
··· 738 738 </dialog> 739 739 } 740 740 741 - // ReportDialogModalProps defines properties for the report dialog 742 - type ReportDialogModalProps struct { 743 - SubjectURI string 744 - SubjectCID string 745 - } 746 - 747 - // ReportDialogModal renders the report modal for submitting content reports 748 - templ ReportDialogModal(props ReportDialogModalProps) { 749 - <dialog id="report-modal" class="modal-dialog" x-data="{ reason: '', charCount: 0, submitting: false, error: '', success: false }"> 750 - <div class="modal-content"> 751 - <h3 class="modal-title">Report Content</h3> 752 - <template x-if="!success"> 753 - <form 754 - @submit.prevent=" 755 - submitting = true; 756 - error = ''; 757 - const dialog = document.getElementById('report-modal'); 758 - const body = new URLSearchParams({ 759 - subject_uri: $el.querySelector('[name=subject_uri]').value, 760 - subject_cid: $el.querySelector('[name=subject_cid]').value, 761 - reason: reason 762 - }); 763 - fetch('/api/report', { 764 - method: 'POST', 765 - headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 766 - body: body 767 - }) 768 - .then(r => r.json().then(data => ({ok: r.ok, data}))) 769 - .then(({ok, data}) => { 770 - submitting = false; 771 - if (ok) { 772 - success = true; 773 - setTimeout(() => dialog.close(), 2000); 774 - } else { 775 - error = data.message || 'Failed to submit report'; 776 - } 777 - }) 778 - .catch(() => { 779 - submitting = false; 780 - error = 'Network error. Please try again.'; 781 - }); 782 - " 783 - class="space-y-4" 784 - > 785 - <input type="hidden" name="subject_uri" value={ props.SubjectURI }/> 786 - <input type="hidden" name="subject_cid" value={ props.SubjectCID }/> 787 - <p class="text-sm text-brown-700"> 788 - Please describe why you're reporting this content. Reports are reviewed by moderators. 789 - </p> 790 - <div> 791 - <textarea 792 - x-model="reason" 793 - @input="charCount = reason.length" 794 - name="reason" 795 - placeholder="Describe the issue (optional)" 796 - rows="4" 797 - maxlength="500" 798 - class="w-full form-textarea" 799 - ></textarea> 800 - <div class="flex justify-between text-xs text-brown-500 mt-1"> 801 - <span>Optional, but helpful for moderators</span> 802 - <span x-text="charCount + '/500'"></span> 803 - </div> 804 - </div> 805 - <template x-if="error"> 806 - <div class="bg-red-100 border border-red-300 text-red-800 px-3 py-2 rounded-lg text-sm" x-text="error"></div> 807 - </template> 808 - <div class="flex gap-2"> 809 - <button 810 - type="submit" 811 - class="flex-1 btn-primary" 812 - :disabled="submitting" 813 - > 814 - <span x-show="!submitting">Submit Report</span> 815 - <span x-show="submitting">Submitting...</span> 816 - </button> 817 - <button 818 - type="button" 819 - @click="$el.closest('dialog').close()" 820 - class="flex-1 btn-secondary" 821 - :disabled="submitting" 822 - > 823 - Cancel 824 - </button> 825 - </div> 826 - </form> 827 - </template> 828 - <template x-if="success"> 829 - <div class="text-center py-4"> 830 - <div class="text-green-600 mb-2"> 831 - <svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 832 - <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> 833 - </svg> 834 - </div> 835 - <p class="font-medium text-brown-900">Report Submitted</p> 836 - <p class="text-sm text-brown-600 mt-1">Thank you for helping keep our community safe.</p> 837 - </div> 838 - </template> 839 - </div> 840 - </dialog> 841 - } 842 - 843 741 // roasterPickerInit generates Alpine.js x-data for the inline roaster picker. 844 742 func roasterPickerInit(bean *models.Bean, roasters []models.Roaster) string { 845 743 // Build JSON array of roasters for Alpine
+34 -125
internal/web/components/entity_tables.templ
··· 5 5 "arabica/internal/models" 6 6 "arabica/internal/web/bff" 7 7 "fmt" 8 + "time" 8 9 ) 10 + 11 + // entityCardHeader renders the date + edit/delete actions header used by all entity cards. 12 + templ entityCardHeader(createdAt time.Time, showActions bool, modalPath string, deletePath string, entityName string) { 13 + <div class="flex items-center justify-between mb-2"> 14 + <div class="text-sm text-brown-600"> 15 + if !createdAt.IsZero() { 16 + <time datetime={ bff.FormatISO(createdAt) } data-local="date">{ createdAt.Format("Jan 2, 2006") }</time> 17 + } 18 + </div> 19 + if showActions { 20 + <div class="flex items-center gap-1"> 21 + <button 22 + hx-get={ modalPath } 23 + hx-target="#modal-container" 24 + hx-swap="innerHTML" 25 + class="text-brown-600 hover:text-brown-900 text-sm font-medium px-1.5 py-0.5 rounded hover:bg-brown-200" 26 + >Edit</button> 27 + <button 28 + hx-delete={ deletePath } 29 + hx-confirm={ "Are you sure you want to delete this " + entityName + "?" } 30 + hx-target="closest .feed-card" 31 + hx-swap="outerHTML swap:0.3s" 32 + class="text-brown-500 hover:text-brown-800 text-sm font-medium px-1.5 py-0.5 rounded hover:bg-brown-200" 33 + >Delete</button> 34 + </div> 35 + } 36 + </div> 37 + } 9 38 10 39 // entityCount looks up a count for an entity by building its AT-URI from the owner DID and rkey. 11 40 func entityCount(counts map[string]int, ownerDID, nsid, rkey string) int { ··· 49 78 // BeanCard renders a single bean as a compact card 50 79 templ BeanCard(bean *models.Bean, showActions bool, ownerHandle string, brewCount int, avgBrewRating float64) { 51 80 <div class="feed-card feed-card-bean"> 52 - <!-- Header: date + actions --> 53 - <div class="flex items-center justify-between mb-2"> 54 - <div class="text-sm text-brown-600"> 55 - if !bean.CreatedAt.IsZero() { 56 - <time datetime={ bff.FormatISO(bean.CreatedAt) } data-local="date">{ bean.CreatedAt.Format("Jan 2, 2006") }</time> 57 - } 58 - </div> 59 - if showActions { 60 - <div class="flex items-center gap-1"> 61 - <button 62 - hx-get={ "/api/modals/bean/" + bean.RKey } 63 - hx-target="#modal-container" 64 - hx-swap="innerHTML" 65 - class="text-brown-600 hover:text-brown-900 text-sm font-medium px-1.5 py-0.5 rounded hover:bg-brown-200" 66 - >Edit</button> 67 - <button 68 - hx-delete={ "/api/beans/" + bean.RKey } 69 - hx-confirm="Are you sure you want to delete this bean?" 70 - hx-target="closest .feed-card" 71 - hx-swap="outerHTML swap:0.3s" 72 - class="text-brown-500 hover:text-brown-800 text-sm font-medium px-1.5 py-0.5 rounded hover:bg-brown-200" 73 - >Delete</button> 74 - </div> 75 - } 76 - </div> 81 + @entityCardHeader(bean.CreatedAt, showActions, "/api/modals/bean/"+bean.RKey, "/api/beans/"+bean.RKey, "bean") 77 82 <div class="feed-content-box-sm"> 78 83 <div class="flex items-start justify-between gap-3 mb-2"> 79 84 <div class="min-w-0"> ··· 183 188 // RoasterCard renders a single roaster as a compact card 184 189 templ RoasterCard(roaster *models.Roaster, showActions bool, ownerHandle string, beanCount int, avgBrewRating float64) { 185 190 <div class="feed-card feed-card-roaster"> 186 - <!-- Header: date + actions --> 187 - <div class="flex items-center justify-between mb-2"> 188 - <div class="text-sm text-brown-600"> 189 - if !roaster.CreatedAt.IsZero() { 190 - <time datetime={ bff.FormatISO(roaster.CreatedAt) } data-local="date">{ roaster.CreatedAt.Format("Jan 2, 2006") }</time> 191 - } 192 - </div> 193 - if showActions { 194 - <div class="flex items-center gap-1"> 195 - <button 196 - hx-get={ "/api/modals/roaster/" + roaster.RKey } 197 - hx-target="#modal-container" 198 - hx-swap="innerHTML" 199 - class="text-brown-600 hover:text-brown-900 text-sm font-medium px-1.5 py-0.5 rounded hover:bg-brown-200" 200 - >Edit</button> 201 - <button 202 - hx-delete={ "/api/roasters/" + roaster.RKey } 203 - hx-confirm="Are you sure you want to delete this roaster?" 204 - hx-target="closest .feed-card" 205 - hx-swap="outerHTML swap:0.3s" 206 - class="text-brown-500 hover:text-brown-800 text-sm font-medium px-1.5 py-0.5 rounded hover:bg-brown-200" 207 - >Delete</button> 208 - </div> 209 - } 210 - </div> 191 + @entityCardHeader(roaster.CreatedAt, showActions, "/api/modals/roaster/"+roaster.RKey, "/api/roasters/"+roaster.RKey, "roaster") 211 192 <div class="feed-content-box-sm"> 212 193 <div class="flex items-start justify-between gap-2 mb-2"> 213 194 <div class="min-w-0"> ··· 283 264 // GrinderCard renders a single grinder as a compact card 284 265 templ GrinderCard(grinder *models.Grinder, showActions bool, ownerHandle string, brewCount int) { 285 266 <div class="feed-card feed-card-grinder"> 286 - <!-- Header: date + actions --> 287 - <div class="flex items-center justify-between mb-2"> 288 - <div class="text-sm text-brown-600"> 289 - if !grinder.CreatedAt.IsZero() { 290 - <time datetime={ bff.FormatISO(grinder.CreatedAt) } data-local="date">{ grinder.CreatedAt.Format("Jan 2, 2006") }</time> 291 - } 292 - </div> 293 - if showActions { 294 - <div class="flex items-center gap-1"> 295 - <button 296 - hx-get={ "/api/modals/grinder/" + grinder.RKey } 297 - hx-target="#modal-container" 298 - hx-swap="innerHTML" 299 - class="text-brown-600 hover:text-brown-900 text-sm font-medium px-1.5 py-0.5 rounded hover:bg-brown-200" 300 - >Edit</button> 301 - <button 302 - hx-delete={ "/api/grinders/" + grinder.RKey } 303 - hx-confirm="Are you sure you want to delete this grinder?" 304 - hx-target="closest .feed-card" 305 - hx-swap="outerHTML swap:0.3s" 306 - class="text-brown-500 hover:text-brown-800 text-sm font-medium px-1.5 py-0.5 rounded hover:bg-brown-200" 307 - >Delete</button> 308 - </div> 309 - } 310 - </div> 267 + @entityCardHeader(grinder.CreatedAt, showActions, "/api/modals/grinder/"+grinder.RKey, "/api/grinders/"+grinder.RKey, "grinder") 311 268 <div class="feed-content-box-sm"> 312 269 <div class="flex items-start justify-between gap-2 mb-2"> 313 270 <div class="min-w-0"> ··· 374 331 // BrewerCard renders a single brewer as a compact card 375 332 templ BrewerCard(brewer *models.Brewer, showActions bool, ownerHandle string, brewCount int) { 376 333 <div class="feed-card feed-card-brewer"> 377 - <!-- Header: date + actions --> 378 - <div class="flex items-center justify-between mb-2"> 379 - <div class="text-sm text-brown-600"> 380 - if !brewer.CreatedAt.IsZero() { 381 - <time datetime={ bff.FormatISO(brewer.CreatedAt) } data-local="date">{ brewer.CreatedAt.Format("Jan 2, 2006") }</time> 382 - } 383 - </div> 384 - if showActions { 385 - <div class="flex items-center gap-1"> 386 - <button 387 - hx-get={ "/api/modals/brewer/" + brewer.RKey } 388 - hx-target="#modal-container" 389 - hx-swap="innerHTML" 390 - class="text-brown-600 hover:text-brown-900 text-sm font-medium px-1.5 py-0.5 rounded hover:bg-brown-200" 391 - >Edit</button> 392 - <button 393 - hx-delete={ "/api/brewers/" + brewer.RKey } 394 - hx-confirm="Are you sure you want to delete this brewer?" 395 - hx-target="closest .feed-card" 396 - hx-swap="outerHTML swap:0.3s" 397 - class="text-brown-500 hover:text-brown-800 text-sm font-medium px-1.5 py-0.5 rounded hover:bg-brown-200" 398 - >Delete</button> 399 - </div> 400 - } 401 - </div> 334 + @entityCardHeader(brewer.CreatedAt, showActions, "/api/modals/brewer/"+brewer.RKey, "/api/brewers/"+brewer.RKey, "brewer") 402 335 <div class="feed-content-box-sm"> 403 336 <div class="flex items-start justify-between gap-2 mb-2"> 404 337 <div class="min-w-0"> ··· 456 389 // RecipeCard renders a single recipe as a compact card 457 390 templ RecipeCard(recipe *models.Recipe, showActions bool) { 458 391 <div class="feed-card feed-card-recipe"> 459 - <!-- Header: date + actions --> 460 - <div class="flex items-center justify-between mb-2"> 461 - <div class="text-sm text-brown-600"> 462 - if !recipe.CreatedAt.IsZero() { 463 - <time datetime={ bff.FormatISO(recipe.CreatedAt) } data-local="date">{ recipe.CreatedAt.Format("Jan 2, 2006") }</time> 464 - } 465 - </div> 466 - if showActions { 467 - <div class="flex items-center gap-1"> 468 - <button 469 - hx-get={ "/api/modals/recipe/" + recipe.RKey } 470 - hx-target="#modal-container" 471 - hx-swap="innerHTML" 472 - class="text-brown-600 hover:text-brown-900 text-sm font-medium px-1.5 py-0.5 rounded hover:bg-brown-200" 473 - >Edit</button> 474 - <button 475 - hx-delete={ "/api/recipes/" + recipe.RKey } 476 - hx-confirm="Are you sure you want to delete this recipe?" 477 - hx-target="closest .feed-card" 478 - hx-swap="outerHTML swap:0.3s" 479 - class="text-brown-500 hover:text-brown-800 text-sm font-medium px-1.5 py-0.5 rounded hover:bg-brown-200" 480 - >Delete</button> 481 - </div> 482 - } 483 - </div> 392 + @entityCardHeader(recipe.CreatedAt, showActions, "/api/modals/recipe/"+recipe.RKey, "/api/recipes/"+recipe.RKey, "recipe") 484 393 <div class="feed-content-box-sm"> 485 394 <div class="flex items-start justify-between gap-2 mb-2"> 486 395 <div class="min-w-0">
-10
internal/web/components/icons.templ
··· 196 196 </svg> 197 197 } 198 198 199 - // IconRefresh renders a refresh/sync icon 200 - templ IconRefresh() { 201 - <svg class="inline-block w-4 h-4 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 202 - <path d="M21.5 2v6h-6"></path> 203 - <path d="M2.5 22v-6h6"></path> 204 - <path d="M2 11.5a10 10 0 0 1 18.8-4.3L21.5 8"></path> 205 - <path d="M22 12.5a10 10 0 0 1-18.8 4.2L2.5 16"></path> 206 - </svg> 207 - } 208 - 209 199 // IconTag renders a tag icon (for type/category metadata) 210 200 templ IconFork() { 211 201 <svg class="inline-block w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
-34
internal/web/components/shared.templ
··· 161 161 </div> 162 162 } 163 163 164 - type LoadingSkeletonTableProps struct { 165 - Columns int 166 - Rows int 167 - } 168 - 169 - templ LoadingSkeletonTable(props LoadingSkeletonTableProps) { 170 - <div class="animate-pulse"> 171 - <div class="table-container overflow-x-auto"> 172 - <table class="table"> 173 - <thead class="table-header"> 174 - <tr> 175 - for i := 0; i < props.Columns; i++ { 176 - <th class="table-th"> 177 - <div class="h-3 bg-brown-300 rounded w-20"></div> 178 - </th> 179 - } 180 - </tr> 181 - </thead> 182 - <tbody class="table-body"> 183 - for i := 0; i < props.Rows; i++ { 184 - <tr> 185 - for j := 0; j < props.Columns; j++ { 186 - <td class="px-6 py-4"> 187 - <div class="h-4 bg-brown-300 rounded w-24"></div> 188 - </td> 189 - } 190 - </tr> 191 - } 192 - </tbody> 193 - </table> 194 - </div> 195 - </div> 196 - } 197 - 198 164 type WelcomeCardProps struct { 199 165 IsAuthenticated bool 200 166 UserDID string
-53
internal/web/components/social_buttons.templ
··· 1 - package components 2 - 3 - // SocialButtonsProps defines properties for the social buttons cluster 4 - type SocialButtonsProps struct { 5 - // Like button props 6 - SubjectURI string // AT-URI of the record being liked 7 - SubjectCID string // CID of the record being liked 8 - IsLiked bool // Whether the current user has liked this record 9 - LikeCount int // Number of likes on this record 10 - 11 - // Comment button props 12 - CommentCount int // Number of comments on this record 13 - ShowComment bool // Whether to show the comment button 14 - 15 - // Share button props 16 - ShareURL string // URL to share 17 - ShareTitle string // Title for native share dialog 18 - ShareText string // Text for native share dialog 19 - 20 - // Display options 21 - ShowLike bool // Whether to show the like button (requires authentication) 22 - IsAuthenticated bool // Whether the user is authenticated (controls click behavior) 23 - } 24 - 25 - // SocialButtons renders a cluster of social interaction buttons (like, comment, share) 26 - templ SocialButtons(props SocialButtonsProps) { 27 - <div class="flex items-center gap-2"> 28 - if props.ShowLike && props.SubjectURI != "" && props.SubjectCID != "" { 29 - @LikeButton(LikeButtonProps{ 30 - SubjectURI: props.SubjectURI, 31 - SubjectCID: props.SubjectCID, 32 - IsLiked: props.IsLiked, 33 - LikeCount: props.LikeCount, 34 - IsAuthenticated: props.IsAuthenticated, 35 - }) 36 - } 37 - if props.ShowComment && props.SubjectURI != "" && props.SubjectCID != "" { 38 - @CommentButton(CommentButtonProps{ 39 - SubjectURI: props.SubjectURI, 40 - SubjectCID: props.SubjectCID, 41 - CommentCount: props.CommentCount, 42 - IsAuthenticated: props.IsAuthenticated, 43 - }) 44 - } 45 - if props.ShareURL != "" { 46 - @ShareButton(ShareButtonProps{ 47 - URL: props.ShareURL, 48 - Title: props.ShareTitle, 49 - Text: props.ShareText, 50 - }) 51 - } 52 - </div> 53 - }