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.

feat: feed cards, filter, and view pages for recipes

authored by

Patrick Dewey and committed by tangled.org 357c2340 fd220334

+319 -1
+1
internal/firehose/index.go
··· 33 33 lexicons.RecordTypeRoaster: true, 34 34 lexicons.RecordTypeGrinder: true, 35 35 lexicons.RecordTypeBrewer: true, 36 + lexicons.RecordTypeRecipe: true, 36 37 } 37 38 38 39 // IndexedRecord represents a record stored in the index
+156
internal/handlers/entity_views.go
··· 500 500 } 501 501 } 502 502 503 + // HandleRecipeView displays a recipe detail page 504 + func (h *Handler) HandleRecipeView(w http.ResponseWriter, r *http.Request) { 505 + rkey := validateRKey(w, r.PathValue("id")) 506 + if rkey == "" { 507 + return 508 + } 509 + 510 + owner := r.URL.Query().Get("owner") 511 + didStr, err := atproto.GetAuthenticatedDID(r.Context()) 512 + isAuthenticated := err == nil && didStr != "" 513 + 514 + var userProfile *bff.UserProfile 515 + if isAuthenticated { 516 + userProfile = h.getUserProfile(r.Context(), didStr) 517 + } 518 + 519 + var props pages.RecipeViewProps 520 + var subjectURI, subjectCID, entityOwnerDID string 521 + 522 + if owner != "" { 523 + entityOwnerDID, err = resolveOwnerDID(r.Context(), owner) 524 + if err != nil { 525 + http.Error(w, "User not found", http.StatusNotFound) 526 + return 527 + } 528 + 529 + publicClient := atproto.NewPublicClient() 530 + record, err := publicClient.GetRecord(r.Context(), entityOwnerDID, atproto.NSIDRecipe, rkey) 531 + if err != nil { 532 + http.Error(w, "Recipe not found", http.StatusNotFound) 533 + return 534 + } 535 + 536 + subjectURI = record.URI 537 + subjectCID = record.CID 538 + 539 + recipe, err := atproto.RecordToRecipe(record.Value, record.URI) 540 + if err != nil { 541 + http.Error(w, "Failed to load recipe", http.StatusInternalServerError) 542 + return 543 + } 544 + recipe.RKey = rkey 545 + 546 + // Resolve brewer reference if present 547 + if brewerRef, ok := record.Value["brewerRef"].(string); ok && brewerRef != "" { 548 + if components, err := atproto.ResolveATURI(brewerRef); err == nil { 549 + recipe.BrewerRKey = components.RKey 550 + } 551 + brewerRKey := atproto.ExtractRKeyFromURI(brewerRef) 552 + if brewerRKey != "" { 553 + brewerRecord, err := publicClient.GetRecord(r.Context(), entityOwnerDID, atproto.NSIDBrewer, brewerRKey) 554 + if err == nil { 555 + if brewer, err := atproto.RecordToBrewer(brewerRecord.Value, brewerRecord.URI); err == nil { 556 + brewer.RKey = brewerRKey 557 + recipe.BrewerObj = brewer 558 + } 559 + } 560 + } 561 + } 562 + 563 + recipe.Interpolate() 564 + props.Recipe = recipe 565 + props.IsOwnProfile = isAuthenticated && didStr == entityOwnerDID 566 + } else { 567 + store, authenticated := h.getAtprotoStore(r) 568 + if !authenticated { 569 + http.Redirect(w, r, "/login", http.StatusFound) 570 + return 571 + } 572 + 573 + atprotoStore, ok := store.(*atproto.AtprotoStore) 574 + if !ok { 575 + http.Error(w, "Internal error", http.StatusInternalServerError) 576 + return 577 + } 578 + 579 + recipeRecord, err := atprotoStore.GetRecipeRecordByRKey(r.Context(), rkey) 580 + if err != nil { 581 + http.Error(w, "Recipe not found", http.StatusNotFound) 582 + return 583 + } 584 + 585 + recipeRecord.Recipe.Interpolate() 586 + props.Recipe = recipeRecord.Recipe 587 + subjectURI = recipeRecord.URI 588 + subjectCID = recipeRecord.CID 589 + props.IsOwnProfile = true 590 + } 591 + 592 + var shareURL string 593 + if owner != "" { 594 + shareURL = fmt.Sprintf("/recipes/%s?owner=%s", rkey, owner) 595 + } else if userProfile != nil && userProfile.Handle != "" { 596 + shareURL = fmt.Sprintf("/recipes/%s?owner=%s", rkey, userProfile.Handle) 597 + } 598 + 599 + layoutData := h.buildLayoutData(r, props.Recipe.Name, isAuthenticated, didStr, userProfile) 600 + h.populateRecipeOGMetadata(layoutData, props.Recipe, shareURL) 601 + 602 + sd := h.fetchSocialData(r.Context(), subjectURI, didStr, isAuthenticated) 603 + 604 + props.IsAuthenticated = isAuthenticated 605 + props.SubjectURI = subjectURI 606 + props.SubjectCID = subjectCID 607 + props.IsLiked = sd.IsLiked 608 + props.LikeCount = sd.LikeCount 609 + props.CommentCount = sd.CommentCount 610 + props.Comments = sd.Comments 611 + props.CurrentUserDID = didStr 612 + props.ShareURL = shareURL 613 + props.IsModerator = sd.IsModerator 614 + props.CanHideRecord = sd.CanHideRecord 615 + props.CanBlockUser = sd.CanBlockUser 616 + props.IsRecordHidden = sd.IsRecordHidden 617 + props.AuthorDID = entityOwnerDID 618 + 619 + if err := pages.RecipeView(layoutData, props).Render(r.Context(), w); err != nil { 620 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 621 + log.Error().Err(err).Msg("Failed to render recipe view") 622 + } 623 + } 624 + 503 625 // OG metadata helpers for entity types 504 626 505 627 func (h *Handler) populateBeanOGMetadata(layoutData *components.LayoutData, bean *models.Bean, shareURL string) { ··· 627 749 layoutData.OGType = "article" 628 750 layoutData.OGUrl = ogURL 629 751 } 752 + 753 + func (h *Handler) populateRecipeOGMetadata(layoutData *components.LayoutData, recipe *models.Recipe, shareURL string) { 754 + if recipe == nil { 755 + return 756 + } 757 + 758 + var descParts []string 759 + if recipe.CoffeeAmount > 0 { 760 + descParts = append(descParts, fmt.Sprintf("%.0fg coffee", recipe.CoffeeAmount)) 761 + } 762 + if recipe.WaterAmount > 0 { 763 + descParts = append(descParts, fmt.Sprintf("%.0fg water", recipe.WaterAmount)) 764 + } 765 + if recipe.GrindSize != "" { 766 + descParts = append(descParts, recipe.GrindSize+" grind") 767 + } 768 + 769 + var ogDescription string 770 + if len(descParts) > 0 { 771 + ogDescription = strings.Join(descParts, " · ") 772 + } else { 773 + ogDescription = "A coffee recipe on Arabica" 774 + } 775 + 776 + var ogURL string 777 + if h.config.PublicURL != "" && shareURL != "" { 778 + ogURL = h.config.PublicURL + shareURL 779 + } 780 + 781 + layoutData.OGTitle = recipe.Name 782 + layoutData.OGDescription = ogDescription 783 + layoutData.OGType = "article" 784 + layoutData.OGUrl = ogURL 785 + }
+2 -1
internal/routing/routing.go
··· 85 85 mux.Handle("PUT /brews/{id}", cop.Handler(http.HandlerFunc(h.HandleBrewUpdate))) 86 86 mux.Handle("DELETE /brews/{id}", cop.Handler(http.HandlerFunc(h.HandleBrewDelete))) 87 87 mux.HandleFunc("GET /brews/export", h.HandleBrewExport) 88 - mux.HandleFunc("GET /recipes/explore", h.HandleRecipeExplore) 88 + mux.HandleFunc("GET /recipes", h.HandleRecipeExplore) 89 + mux.HandleFunc("GET /recipes/{id}", h.HandleRecipeView) 89 90 90 91 // API routes for CRUD operations 91 92 mux.Handle("POST /api/beans", cop.Handler(http.HandlerFunc(h.HandleBeanCreate)))
+3
internal/web/components/header.templ
··· 77 77 <a href="/brews" class="dropdown-item"> 78 78 My Brews 79 79 </a> 80 + <a href="/recipes" class="dropdown-item"> 81 + Recipes 82 + </a> 80 83 <a href="/manage" class="dropdown-item"> 81 84 Manage Records 82 85 </a>
+6
internal/web/pages/feed.templ
··· 218 218 @components.BrewerContent(item.Brewer) 219 219 </a> 220 220 } 221 + case lexicons.RecordTypeRecipe: 222 + if item.Recipe != nil { 223 + <a href={ templ.SafeURL(getFeedItemShareURL(item)) } class="block hover:opacity-90 transition-opacity"> 224 + @FeedRecipeContent(item) 225 + </a> 226 + } 221 227 } 222 228 <!-- Action bar --> 223 229 if item.SubjectURI != "" && item.SubjectCID != "" {
+151
internal/web/pages/recipe_view.templ
··· 1 + package pages 2 + 3 + import ( 4 + "arabica/internal/firehose" 5 + "arabica/internal/models" 6 + "arabica/internal/web/bff" 7 + "arabica/internal/web/components" 8 + "fmt" 9 + ) 10 + 11 + type RecipeViewProps struct { 12 + Recipe *models.Recipe 13 + IsOwnProfile bool 14 + IsAuthenticated bool 15 + SubjectURI string 16 + SubjectCID string 17 + IsLiked bool 18 + LikeCount int 19 + CommentCount int 20 + Comments []firehose.IndexedComment 21 + CurrentUserDID string 22 + ShareURL string 23 + IsModerator bool 24 + CanHideRecord bool 25 + CanBlockUser bool 26 + IsRecordHidden bool 27 + AuthorDID string 28 + } 29 + 30 + templ RecipeView(layout *components.LayoutData, props RecipeViewProps) { 31 + @components.Layout(layout, RecipeViewContent(props)) 32 + } 33 + 34 + templ RecipeViewContent(props RecipeViewProps) { 35 + <div class="page-container-sm"> 36 + @components.Card(components.CardProps{InnerCard: true}, RecipeViewCard(props)) 37 + </div> 38 + } 39 + 40 + templ RecipeViewCard(props RecipeViewProps) { 41 + @RecipeViewHeader(props) 42 + <div class="space-y-6"> 43 + <!-- Main details grid --> 44 + <div class="grid grid-cols-2 gap-4"> 45 + if props.Recipe.CoffeeAmount > 0 { 46 + @components.DetailField(components.DetailFieldProps{Label: "☕ Coffee", Value: fmt.Sprintf("%.1fg", props.Recipe.CoffeeAmount)}) 47 + } 48 + if props.Recipe.WaterAmount > 0 { 49 + @components.DetailField(components.DetailFieldProps{Label: "💧 Water", Value: fmt.Sprintf("%.1fg", props.Recipe.WaterAmount)}) 50 + } 51 + if props.Recipe.Ratio > 0 { 52 + @components.DetailField(components.DetailFieldProps{Label: "⚖️ Ratio", Value: fmt.Sprintf("1:%.1f", props.Recipe.Ratio)}) 53 + } 54 + if props.Recipe.GrindSize != "" { 55 + @components.DetailField(components.DetailFieldProps{Label: "🔧 Grind Size", Value: props.Recipe.GrindSize}) 56 + } 57 + if props.Recipe.BrewerObj != nil { 58 + @components.DetailField(components.DetailFieldProps{ 59 + Label: "🫖 Brewer", 60 + Value: props.Recipe.BrewerObj.Name, 61 + LinkHref: recipeBrewerLink(props), 62 + }) 63 + } else if props.Recipe.BrewerType != "" { 64 + @components.DetailField(components.DetailFieldProps{Label: "🫖 Brewer Type", Value: props.Recipe.BrewerType}) 65 + } 66 + </div> 67 + <!-- Pours --> 68 + if len(props.Recipe.Pours) > 0 { 69 + <div class="section-box"> 70 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">💧 Pours</h3> 71 + <div class="space-y-2"> 72 + for i, pour := range props.Recipe.Pours { 73 + <div class="flex items-center gap-4 bg-brown-50 rounded-lg px-3 py-2 border border-brown-200"> 74 + <span class="text-sm font-medium text-brown-700">Pour { fmt.Sprintf("%d", i+1) }</span> 75 + <span class="text-sm text-brown-900">{ fmt.Sprintf("%dg", pour.WaterAmount) }</span> 76 + if pour.TimeSeconds > 0 { 77 + <span class="text-sm text-brown-600">at { fmt.Sprintf("%ds", pour.TimeSeconds) }</span> 78 + } 79 + </div> 80 + } 81 + </div> 82 + </div> 83 + } 84 + <!-- Notes --> 85 + if props.Recipe.Notes != "" { 86 + <div class="section-box"> 87 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">📝 Notes</h3> 88 + <div class="text-brown-900 whitespace-pre-wrap">{ props.Recipe.Notes }</div> 89 + </div> 90 + } 91 + <!-- Action bar --> 92 + <div class="flex justify-between items-center"> 93 + @components.BackButton() 94 + <div class="bg-brown-50 rounded-lg px-3 py-2 border border-brown-200 brew-view-actions"> 95 + @components.ActionBar(components.ActionBarProps{ 96 + SubjectURI: props.SubjectURI, 97 + SubjectCID: props.SubjectCID, 98 + IsLiked: props.IsLiked, 99 + LikeCount: props.LikeCount, 100 + CommentCount: props.CommentCount, 101 + ShowComments: true, 102 + ShareURL: props.ShareURL, 103 + ShareTitle: props.Recipe.Name, 104 + ShareText: "Check out this recipe on Arabica", 105 + IsOwner: props.IsOwnProfile, 106 + EditModalURL: "/api/modals/recipe/" + props.Recipe.RKey, 107 + DeleteURL: "/api/recipes/" + props.Recipe.RKey, 108 + DeleteRedirect: "/manage", 109 + IsAuthenticated: props.IsAuthenticated, 110 + IsModerator: props.IsModerator, 111 + CanHideRecord: props.CanHideRecord, 112 + CanBlockUser: props.CanBlockUser, 113 + IsRecordHidden: props.IsRecordHidden, 114 + AuthorDID: props.AuthorDID, 115 + }) 116 + </div> 117 + </div> 118 + <!-- Comments --> 119 + @components.CommentSection(components.CommentSectionProps{ 120 + SubjectURI: props.SubjectURI, 121 + SubjectCID: props.SubjectCID, 122 + Comments: props.Comments, 123 + IsAuthenticated: props.IsAuthenticated, 124 + CurrentUserDID: props.CurrentUserDID, 125 + ModCtx: components.CommentModerationContext{ 126 + IsModerator: props.IsModerator, 127 + CanHideRecord: props.CanHideRecord, 128 + CanBlockUser: props.CanBlockUser, 129 + }, 130 + ViewURL: props.ShareURL, 131 + }) 132 + </div> 133 + } 134 + 135 + templ RecipeViewHeader(props RecipeViewProps) { 136 + <div class="mb-6"> 137 + <h2 class="text-3xl font-bold text-brown-900">{ props.Recipe.Name }</h2> 138 + <p class="text-sm text-brown-600 mt-1"><time datetime={ bff.FormatISO(props.Recipe.CreatedAt) } data-local="long">{ props.Recipe.CreatedAt.Format("January 2, 2006 at 3:04 PM") }</time></p> 139 + </div> 140 + } 141 + 142 + func recipeBrewerLink(props RecipeViewProps) string { 143 + if props.Recipe.BrewerObj == nil || props.Recipe.BrewerRKey == "" { 144 + return "" 145 + } 146 + owner := getOwnerFromShareURL(props.ShareURL) 147 + if owner != "" { 148 + return fmt.Sprintf("/brewers/%s?owner=%s", props.Recipe.BrewerRKey, owner) 149 + } 150 + return "" 151 + }