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: matching package and brewer matching on recipe forks

authored by

Patrick Dewey and committed by tangled.org da429e52 8838ed09

+432 -34
+25
internal/firehose/index.go
··· 1017 1017 return counts 1018 1018 } 1019 1019 1020 + // BrewCountsByRecipeURI returns a map of recipe AT-URI -> number of brews referencing that recipe. 1021 + // Uses SQLite json_extract to efficiently query the recipeRef field in brew records. 1022 + func (idx *FeedIndex) BrewCountsByRecipeURI() map[string]int { 1023 + counts := make(map[string]int) 1024 + rows, err := idx.db.Query(` 1025 + SELECT json_extract(record, '$.recipeRef') as recipe_uri, COUNT(*) as cnt 1026 + FROM records 1027 + WHERE collection = 'social.arabica.alpha.brew' 1028 + AND recipe_uri IS NOT NULL AND recipe_uri != '' 1029 + GROUP BY recipe_uri 1030 + `) 1031 + if err != nil { 1032 + return counts 1033 + } 1034 + defer rows.Close() 1035 + for rows.Next() { 1036 + var uri string 1037 + var count int 1038 + if err := rows.Scan(&uri, &count); err == nil { 1039 + counts[uri] = count 1040 + } 1041 + } 1042 + return counts 1043 + } 1044 + 1020 1045 func formatTimeAgo(t time.Time) string { 1021 1046 now := time.Now() 1022 1047 diff := now.Sub(t)
+17
internal/handlers/entity_views.go
··· 751 751 props.IsRecordHidden = sd.IsRecordHidden 752 752 props.AuthorDID = entityOwnerDID 753 753 754 + // Resolve source recipe provenance if this is a fork 755 + if props.Recipe.SourceRef != "" { 756 + if sourceComponents, err := atproto.ResolveATURI(props.Recipe.SourceRef); err == nil { 757 + // Build a view URL for the source recipe 758 + sourceOwner := sourceComponents.DID 759 + if profile, err := h.feedIndex.GetProfile(r.Context(), sourceComponents.DID); err == nil && profile != nil { 760 + sourceOwner = profile.Handle 761 + if profile.DisplayName != nil && *profile.DisplayName != "" { 762 + props.SourceRecipeAuthor = *profile.DisplayName 763 + } else { 764 + props.SourceRecipeAuthor = profile.Handle 765 + } 766 + } 767 + props.SourceRecipeURL = fmt.Sprintf("/recipes/%s?owner=%s", sourceComponents.RKey, sourceOwner) 768 + } 769 + } 770 + 754 771 if err := pages.RecipeView(layoutData, props).Render(r.Context(), w); err != nil { 755 772 http.Error(w, "Failed to render page", http.StatusInternalServerError) 756 773 log.Error().Err(err).Msg("Failed to render recipe view")
+127 -18
internal/handlers/recipe.go
··· 8 8 "strconv" 9 9 10 10 "arabica/internal/atproto" 11 + "arabica/internal/matching" 11 12 "arabica/internal/models" 12 13 "arabica/internal/web/components" 13 14 "arabica/internal/web/pages" ··· 301 302 // Build the source AT-URI for provenance 302 303 sourceURI := atproto.BuildATURI(ownerDID, atproto.NSIDRecipe, rkey) 303 304 305 + // Resolve brewer: try to match source brewer to current user's brewers 306 + var brewerRKey, brewerType string 307 + if brewerRef, ok := record.Value["brewerRef"].(string); ok && brewerRef != "" { 308 + // Fetch the source brewer to get name and type for matching 309 + brewerRKeySource := atproto.ExtractRKeyFromURI(brewerRef) 310 + if brewerRKeySource != "" { 311 + if sourceBrewer, err := publicClient.GetRecord(r.Context(), ownerDID, atproto.NSIDBrewer, brewerRKeySource); err == nil { 312 + var sourceName, sourceType string 313 + if n, ok := sourceBrewer.Value["name"].(string); ok { 314 + sourceName = n 315 + } 316 + if t, ok := sourceBrewer.Value["brewerType"].(string); ok { 317 + sourceType = t 318 + brewerType = t 319 + } 320 + 321 + // Match against the current user's brewers 322 + if userBrewers, err := store.ListBrewers(r.Context()); err == nil { 323 + candidates := make([]matching.Candidate, len(userBrewers)) 324 + for i, b := range userBrewers { 325 + candidates[i] = matching.Candidate{RKey: b.RKey, Name: b.Name, Type: b.BrewerType} 326 + } 327 + if m := matching.Match(sourceName, sourceType, candidates); m != nil { 328 + brewerRKey = m.RKey 329 + log.Debug().Str("matched", m.Name).Float64("score", m.Score).Msg("Matched brewer for recipe fork") 330 + } 331 + } 332 + } 333 + } 334 + } 335 + if brewerType == "" { 336 + brewerType = sourceRecipe.BrewerType 337 + } 338 + 304 339 // Create a copy in the current user's PDS 305 340 req := &models.CreateRecipeRequest{ 306 341 Name: sourceRecipe.Name, 307 - BrewerType: sourceRecipe.BrewerType, 342 + BrewerRKey: brewerRKey, 343 + BrewerType: brewerType, 308 344 CoffeeAmount: sourceRecipe.CoffeeAmount, 309 345 WaterAmount: sourceRecipe.WaterAmount, 310 346 GrindSize: sourceRecipe.GrindSize, ··· 396 432 return nil, err 397 433 } 398 434 399 - // Batch-collect unique DIDs for profile lookups 435 + // Batch-collect unique DIDs for profile lookups (authors + source refs) 400 436 didSet := make(map[string]struct{}, len(records)) 401 437 for _, rec := range records { 402 438 didSet[rec.DID] = struct{}{} 403 439 } 404 440 405 - // Resolve profiles for all authors 441 + // Pre-scan records for sourceRef DIDs so we can batch profile lookups 442 + type parsedRecord struct { 443 + uri string 444 + did string 445 + data map[string]interface{} 446 + recipe *models.Recipe 447 + sourceRef string 448 + sourceDID string 449 + sourceRKey string 450 + } 451 + parsed := make([]parsedRecord, 0, len(records)) 452 + for i := range records { 453 + var recordData map[string]interface{} 454 + if err := json.Unmarshal(records[i].Record, &recordData); err != nil { 455 + continue 456 + } 457 + recipe, err := atproto.RecordToRecipe(recordData, records[i].URI) 458 + if err != nil { 459 + continue 460 + } 461 + 462 + pr := parsedRecord{uri: records[i].URI, did: records[i].DID, data: recordData, recipe: recipe} 463 + if recipe.SourceRef != "" { 464 + if c, err := atproto.ResolveATURI(recipe.SourceRef); err == nil { 465 + pr.sourceDID = c.DID 466 + pr.sourceRKey = c.RKey 467 + pr.sourceRef = recipe.SourceRef 468 + didSet[c.DID] = struct{}{} 469 + } 470 + } 471 + parsed = append(parsed, pr) 472 + } 473 + 474 + // Resolve profiles for all DIDs (authors + source authors) 406 475 profiles := make(map[string]*atproto.Profile, len(didSet)) 407 476 for did := range didSet { 408 477 profile, err := h.feedIndex.GetProfile(ctx, did) ··· 411 480 } 412 481 } 413 482 414 - recipes := make([]*models.Recipe, 0, len(records)) 415 - for _, rec := range records { 416 - var recordData map[string]interface{} 417 - if err := json.Unmarshal(rec.Record, &recordData); err != nil { 418 - continue 483 + // Build fork map: source URI -> list of forker DIDs 484 + type forkInfo struct { 485 + count int 486 + avatars []string 487 + } 488 + forkMap := make(map[string]*forkInfo) 489 + for _, pr := range parsed { 490 + if pr.sourceRef != "" { 491 + fi, ok := forkMap[pr.sourceRef] 492 + if !ok { 493 + fi = &forkInfo{} 494 + forkMap[pr.sourceRef] = fi 495 + } 496 + fi.count++ 497 + if len(fi.avatars) < 5 { 498 + if p, ok := profiles[pr.did]; ok && p.Avatar != nil && *p.Avatar != "" { 499 + fi.avatars = append(fi.avatars, *p.Avatar) 500 + } 501 + } 419 502 } 503 + } 420 504 421 - recipe, err := atproto.RecordToRecipe(recordData, rec.URI) 422 - if err != nil { 423 - continue 424 - } 505 + // Batch query brew counts per recipe 506 + brewCounts := h.feedIndex.BrewCountsByRecipeURI() 507 + 508 + // Build final recipe list 509 + recipes := make([]*models.Recipe, 0, len(parsed)) 510 + for _, pr := range parsed { 511 + recipe := pr.recipe 425 512 426 513 // Resolve brewer reference from the record data 427 - if brewerRef, ok := recordData["brewerRef"].(string); ok && brewerRef != "" { 514 + if brewerRef, ok := pr.data["brewerRef"].(string); ok && brewerRef != "" { 428 515 if c, parseErr := atproto.ResolveATURI(brewerRef); parseErr == nil { 429 516 recipe.BrewerRKey = c.RKey 430 517 } 431 - // Try to get brewer record from index for display 432 518 if brewerRec, getErr := h.feedIndex.GetRecord(brewerRef); getErr == nil && brewerRec != nil { 433 519 var brewerData map[string]interface{} 434 520 if err := json.Unmarshal(brewerRec.Record, &brewerData); err == nil { ··· 438 524 } 439 525 } 440 526 } 441 - 442 - // Populate brewer type from resolved brewer if not set 443 527 if recipe.BrewerType == "" && recipe.BrewerObj != nil { 444 528 recipe.BrewerType = recipe.BrewerObj.BrewerType 445 529 } 446 530 447 531 // Populate author info 448 - recipe.AuthorDID = rec.DID 449 - if profile, ok := profiles[rec.DID]; ok { 532 + recipe.AuthorDID = pr.did 533 + if profile, ok := profiles[pr.did]; ok { 450 534 recipe.AuthorHandle = profile.Handle 451 535 if profile.Avatar != nil { 452 536 recipe.AuthorAvatar = *profile.Avatar ··· 454 538 if profile.DisplayName != nil { 455 539 recipe.AuthorDisplay = *profile.DisplayName 456 540 } 541 + } 542 + 543 + // Populate source author info 544 + if pr.sourceDID != "" { 545 + if profile, ok := profiles[pr.sourceDID]; ok { 546 + recipe.SourceAuthorHandle = profile.Handle 547 + if profile.Avatar != nil { 548 + recipe.SourceAuthorAvatar = *profile.Avatar 549 + } 550 + if profile.DisplayName != nil && *profile.DisplayName != "" { 551 + recipe.SourceAuthorDisplay = *profile.DisplayName 552 + } else { 553 + recipe.SourceAuthorDisplay = profile.Handle 554 + } 555 + } 556 + } 557 + 558 + // Populate social stats 559 + recipeURI := pr.uri 560 + if fi, ok := forkMap[recipeURI]; ok { 561 + recipe.ForkCount = fi.count 562 + recipe.ForkerAvatars = fi.avatars 563 + } 564 + if bc, ok := brewCounts[recipeURI]; ok { 565 + recipe.BrewCount = bc 457 566 } 458 567 459 568 recipe.Interpolate()
+61
internal/matching/matching.go
··· 1 + // Package matching provides generic entity matching across user records. 2 + // 3 + // When copying or forking records between users, references to entities (brewers, 4 + // beans, grinders, etc.) need to be resolved to equivalent entities in the 5 + // target user's collection. This package provides a reusable matching pipeline 6 + // that can be extended with fuzzy matching in the future. 7 + package matching 8 + 9 + import "strings" 10 + 11 + // Candidate represents a matchable entity from the user's records. 12 + type Candidate struct { 13 + RKey string 14 + Name string 15 + Type string // optional secondary field (e.g. brewer_type) 16 + } 17 + 18 + // Result represents a matched entity with a confidence score. 19 + type Result struct { 20 + RKey string 21 + Name string 22 + Score float64 // 1.0 = exact name match, 0.5 = type-only match 23 + } 24 + 25 + // Match finds the best matching candidate for the given source entity. 26 + // It applies matchers in priority order and returns the first match, or nil. 27 + // 28 + // Current matching strategy (in priority order): 29 + // 1. Exact name match (case-insensitive) 30 + // 2. Single type match — if exactly one candidate shares the same type 31 + // 32 + // Either sourceName or sourceType may be empty; matching adapts accordingly. 33 + func Match(sourceName, sourceType string, candidates []Candidate) *Result { 34 + if len(candidates) == 0 { 35 + return nil 36 + } 37 + 38 + // 1. Exact name match (case-insensitive) 39 + if sourceName != "" { 40 + for _, c := range candidates { 41 + if strings.EqualFold(c.Name, sourceName) { 42 + return &Result{RKey: c.RKey, Name: c.Name, Score: 1.0} 43 + } 44 + } 45 + } 46 + 47 + // 2. Single type match — only when there's exactly one candidate of the same type 48 + if sourceType != "" { 49 + var typeMatches []Candidate 50 + for _, c := range candidates { 51 + if c.Type != "" && strings.EqualFold(c.Type, sourceType) { 52 + typeMatches = append(typeMatches, c) 53 + } 54 + } 55 + if len(typeMatches) == 1 { 56 + return &Result{RKey: typeMatches[0].RKey, Name: typeMatches[0].Name, Score: 0.5} 57 + } 58 + } 59 + 60 + return nil 61 + }
+88
internal/matching/matching_test.go
··· 1 + package matching 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + ) 8 + 9 + func TestMatch_ExactName(t *testing.T) { 10 + candidates := []Candidate{ 11 + {RKey: "abc", Name: "Hario V60 02", Type: "Pour-Over"}, 12 + {RKey: "def", Name: "Chemex", Type: "Pour-Over"}, 13 + } 14 + 15 + result := Match("Hario V60 02", "Pour-Over", candidates) 16 + assert.NotNil(t, result) 17 + assert.Equal(t, "abc", result.RKey) 18 + assert.Equal(t, 1.0, result.Score) 19 + } 20 + 21 + func TestMatch_ExactNameCaseInsensitive(t *testing.T) { 22 + candidates := []Candidate{ 23 + {RKey: "abc", Name: "hario v60 02", Type: "Pour-Over"}, 24 + } 25 + 26 + result := Match("Hario V60 02", "", candidates) 27 + assert.NotNil(t, result) 28 + assert.Equal(t, "abc", result.RKey) 29 + assert.Equal(t, 1.0, result.Score) 30 + } 31 + 32 + func TestMatch_SingleTypeMatch(t *testing.T) { 33 + candidates := []Candidate{ 34 + {RKey: "abc", Name: "My French Press", Type: "French Press"}, 35 + {RKey: "def", Name: "Chemex", Type: "Pour-Over"}, 36 + } 37 + 38 + result := Match("Some Other Press", "French Press", candidates) 39 + assert.NotNil(t, result) 40 + assert.Equal(t, "abc", result.RKey) 41 + assert.Equal(t, 0.5, result.Score) 42 + } 43 + 44 + func TestMatch_AmbiguousType_ReturnsNil(t *testing.T) { 45 + candidates := []Candidate{ 46 + {RKey: "abc", Name: "V60", Type: "Pour-Over"}, 47 + {RKey: "def", Name: "Chemex", Type: "Pour-Over"}, 48 + } 49 + 50 + result := Match("Kalita Wave", "Pour-Over", candidates) 51 + assert.Nil(t, result) 52 + } 53 + 54 + func TestMatch_NoCandidates(t *testing.T) { 55 + result := Match("V60", "Pour-Over", nil) 56 + assert.Nil(t, result) 57 + } 58 + 59 + func TestMatch_NoMatchAtAll(t *testing.T) { 60 + candidates := []Candidate{ 61 + {RKey: "abc", Name: "AeroPress", Type: "Immersion"}, 62 + } 63 + 64 + result := Match("V60", "Pour-Over", candidates) 65 + assert.Nil(t, result) 66 + } 67 + 68 + func TestMatch_EmptySourceName_FallsToType(t *testing.T) { 69 + candidates := []Candidate{ 70 + {RKey: "abc", Name: "My V60", Type: "Pour-Over"}, 71 + } 72 + 73 + result := Match("", "Pour-Over", candidates) 74 + assert.NotNil(t, result) 75 + assert.Equal(t, "abc", result.RKey) 76 + assert.Equal(t, 0.5, result.Score) 77 + } 78 + 79 + func TestMatch_EmptySourceType_NameOnly(t *testing.T) { 80 + candidates := []Candidate{ 81 + {RKey: "abc", Name: "V60", Type: "Pour-Over"}, 82 + } 83 + 84 + result := Match("V60", "", candidates) 85 + assert.NotNil(t, result) 86 + assert.Equal(t, "abc", result.RKey) 87 + assert.Equal(t, 1.0, result.Score) 88 + }
+10
internal/models/models.go
··· 115 115 AuthorHandle string `json:"author_handle,omitempty"` // handle of the creator 116 116 AuthorAvatar string `json:"author_avatar,omitempty"` // avatar URL of the creator 117 117 AuthorDisplay string `json:"author_display,omitempty"` // display name of the creator 118 + 119 + // Source/fork provenance (populated by handler for explore/view) 120 + SourceAuthorHandle string `json:"source_author_handle,omitempty"` 121 + SourceAuthorAvatar string `json:"source_author_avatar,omitempty"` 122 + SourceAuthorDisplay string `json:"source_author_display,omitempty"` 123 + 124 + // Social stats (populated by handler for explore) 125 + ForkCount int `json:"fork_count,omitempty"` 126 + BrewCount int `json:"brew_count,omitempty"` 127 + ForkerAvatars []string `json:"forker_avatars,omitempty"` // up to N forker profile pics 118 128 } 119 129 120 130 // Interpolate fills in computed/derived fields from existing data.
+63
internal/web/pages/recipe_explore.templ
··· 148 148 <th class="table-th whitespace-nowrap">Ratio</th> 149 149 <th class="table-th whitespace-nowrap">Grind</th> 150 150 <th class="table-th whitespace-nowrap">Brewer</th> 151 + <th class="table-th whitespace-nowrap"></th> 151 152 </tr> 152 153 </thead> 153 154 <tbody class="table-body"> ··· 170 171 <td class="px-6 py-4 text-sm text-brown-900" x-text="formatRatio(recipe)"></td> 171 172 <td class="px-6 py-4 text-sm text-brown-900" x-text="recipe.grind_size || '-'"></td> 172 173 <td class="px-6 py-4 text-sm text-brown-900" x-text="getBrewerDisplay(recipe)"></td> 174 + <td class="px-6 py-4 text-sm text-brown-500"> 175 + <div class="flex items-center gap-3"> 176 + <template x-if="recipe.brew_count > 0"> 177 + <span class="flex items-center gap-1" :title="recipe.brew_count + ' brews'"> 178 + <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0 1 12 21 8.25 8.25 0 0 1 6.038 7.047 8.287 8.287 0 0 0 9 9.601a8.983 8.983 0 0 1 3.361-6.867 8.21 8.21 0 0 0 3 2.48Z"></path></svg> 179 + <span x-text="recipe.brew_count"></span> 180 + </span> 181 + </template> 182 + <template x-if="recipe.fork_count > 0"> 183 + <span class="flex items-center gap-1" :title="recipe.fork_count + ' forks'"> 184 + <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"></path></svg> 185 + <span x-text="recipe.fork_count"></span> 186 + </span> 187 + </template> 188 + <template x-if="recipe.forker_avatars && recipe.forker_avatars.length > 0"> 189 + <div class="flex -space-x-1.5"> 190 + <template x-for="(avatar, i) in recipe.forker_avatars.slice(0, 3)" :key="i"> 191 + <img :src="avatar" class="w-5 h-5 rounded-full object-cover border border-white"/> 192 + </template> 193 + </div> 194 + </template> 195 + </div> 196 + </td> 173 197 </tr> 174 198 </template> 175 199 </tbody> ··· 280 304 <span class="text-xs text-brown-600 uppercase">Notes</span> 281 305 <p class="text-sm text-brown-800 mt-1 whitespace-pre-wrap" x-text="selectedRecipe.notes"></p> 282 306 </div> 307 + </template> 308 + <template x-if="selectedRecipe.brew_count > 0 || selectedRecipe.fork_count > 0"> 309 + <div class="flex items-center gap-4 mb-4 text-sm text-brown-600"> 310 + <template x-if="selectedRecipe.brew_count > 0"> 311 + <span class="flex items-center gap-1.5"> 312 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0 1 12 21 8.25 8.25 0 0 1 6.038 7.047 8.287 8.287 0 0 0 9 9.601a8.983 8.983 0 0 1 3.361-6.867 8.21 8.21 0 0 0 3 2.48Z"></path></svg> 313 + <span x-text="selectedRecipe.brew_count + ' brew' + (selectedRecipe.brew_count !== 1 ? 's' : '')"></span> 314 + </span> 315 + </template> 316 + <template x-if="selectedRecipe.fork_count > 0"> 317 + <span class="flex items-center gap-1.5"> 318 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"></path></svg> 319 + <span x-text="selectedRecipe.fork_count + ' fork' + (selectedRecipe.fork_count !== 1 ? 's' : '')"></span> 320 + <template x-if="selectedRecipe.forker_avatars && selectedRecipe.forker_avatars.length > 0"> 321 + <div class="flex -space-x-1.5 ml-1"> 322 + <template x-for="(avatar, i) in selectedRecipe.forker_avatars.slice(0, 5)" :key="i"> 323 + <img :src="avatar" class="w-5 h-5 rounded-full object-cover border border-white"/> 324 + </template> 325 + </div> 326 + </template> 327 + </span> 328 + </template> 329 + </div> 330 + </template> 331 + <template x-if="selectedRecipe.source_ref"> 332 + <p class="text-sm text-brown-500 mb-3"> 333 + Forked from 334 + <template x-if="selectedRecipe.source_author_display || selectedRecipe.source_author_handle"> 335 + <a 336 + :href="getSourceRecipeURL(selectedRecipe)" 337 + class="text-brown-700 underline hover:text-brown-900" 338 + @click.stop 339 + x-text="(selectedRecipe.source_author_display || selectedRecipe.source_author_handle) + '\'s recipe'" 340 + ></a> 341 + </template> 342 + <template x-if="!selectedRecipe.source_author_display && !selectedRecipe.source_author_handle"> 343 + <span>another recipe</span> 344 + </template> 345 + </p> 283 346 </template> 284 347 <div class="flex items-center gap-3"> 285 348 <a
+30 -16
internal/web/pages/recipe_view.templ
··· 9 9 ) 10 10 11 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 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 + SourceRecipeURL string // view URL for the forked-from recipe 29 + SourceRecipeAuthor string // display name or handle of the original author 28 30 } 29 31 30 32 templ RecipeView(layout *components.LayoutData, props RecipeViewProps) { ··· 160 162 <div class="mb-6"> 161 163 <h2 class="text-3xl font-bold text-brown-900">{ props.Recipe.Name }</h2> 162 164 <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> 165 + if props.SourceRecipeURL != "" { 166 + <p class="text-sm text-brown-500 mt-1"> 167 + Forked from 168 + <a href={ templ.SafeURL(props.SourceRecipeURL) } class="text-brown-700 underline hover:text-brown-900"> 169 + if props.SourceRecipeAuthor != "" { 170 + { props.SourceRecipeAuthor + "'s recipe" } 171 + } else { 172 + original recipe 173 + } 174 + </a> 175 + </p> 176 + } 163 177 </div> 164 178 } 165 179
+11
static/js/recipe-explore.js
··· 102 102 } 103 103 }, 104 104 105 + getSourceRecipeURL(recipe) { 106 + if (!recipe || !recipe.source_ref) return "#"; 107 + // source_ref is an AT-URI like at://did/collection/rkey 108 + const parts = recipe.source_ref.replace("at://", "").split("/"); 109 + if (parts.length < 3) return "#"; 110 + const rkey = parts[2]; 111 + const owner = 112 + recipe.source_author_handle || recipe.source_author_display || parts[0]; 113 + return `/recipes/${rkey}?owner=${encodeURIComponent(owner)}`; 114 + }, 115 + 105 116 async forkRecipe() { 106 117 if (!this.selectedRecipe) return; 107 118 const owner =