The recipes.blue monorepo recipes.blue
recipes appview atproto
2
fork

Configure Feed

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

feat: add proper identity resolution

+120 -103
+1
apps/api/package.json
··· 35 35 "zod": "^3.23.8" 36 36 }, 37 37 "devDependencies": { 38 + "@atcute/bluesky": "^1.0.9", 38 39 "@cookware/lexicons": "workspace:*", 39 40 "@cookware/tsconfig": "workspace:*", 40 41 "@swc/core": "^1.9.3",
+1
apps/api/src/env.d.ts
··· 1 + /// <reference types="@atcute/bluesky/lexicons" /> 1 2 /// <reference types="@cookware/lexicons" />
+1 -3
apps/api/src/index.ts
··· 20 20 app.use(cors({ 21 21 origin: (origin, _ctx) => { 22 22 if (env.ENV == 'development') { 23 - const host = _ctx.req.header('Host'); 24 - console.log(`https://${host}`); 25 - return `https://${host}`; 23 + return origin; 26 24 } 27 25 return env.CORS_ORIGINS.includes(origin) 28 26 ? origin
+29
apps/api/src/util/api.ts
··· 1 + import { XRPC } from '@atcute/client'; 2 + import type { AppBskyActorProfile, BlueRecipesFeedDefs } from '@atcute/client/lexicons'; 3 + import { DID, getDidDoc } from '@cookware/lexicons'; 4 + 5 + export const getAuthorInfo = async ( 6 + did: DID, 7 + rpc: XRPC, 8 + ): Promise<BlueRecipesFeedDefs.AuthorInfo> => { 9 + const author = await getDidDoc(did); 10 + const profile = await rpc.get('com.atproto.repo.getRecord', { 11 + params: { 12 + repo: did, 13 + collection: 'app.bsky.actor.profile', 14 + rkey: 'self', 15 + }, 16 + }); 17 + const data = profile.data.value as AppBskyActorProfile.Record; 18 + 19 + let info: BlueRecipesFeedDefs.AuthorInfo = { 20 + did: did, 21 + handle: author.alsoKnownAs[0]?.substring(5) as string, 22 + displayName: data.displayName, 23 + }; 24 + 25 + if (data.avatar) 26 + info['avatarUrl'] = `https://cdn.bsky.app/img/avatar_thumbnail/plain/${did}/${data.avatar.ref.$link}@jpeg`; 27 + 28 + return info; 29 + };
+25 -10
apps/api/src/xrpc/index.ts
··· 2 2 import { db, recipeTable } from '@cookware/database'; 3 3 import { and, desc, eq, sql } from 'drizzle-orm'; 4 4 import { DID, getDidDoc, getDidFromHandleOrDid } from '@cookware/lexicons'; 5 + import { simpleFetchHandler, XRPC } from '@atcute/client'; 6 + import { AppBskyActorProfile } from '@atproto/api'; 7 + import { BlueRecipesFeedDefs, BlueRecipesFeedGetRecipes } from '@atcute/client/lexicons'; 8 + import { getAuthorInfo } from '../util/api.js'; 5 9 6 10 export const xrpcApp = new Hono(); 7 11 ··· 27 31 .where(did ? eq(recipeTable.authorDid, did) : undefined) 28 32 .orderBy(desc(recipeTable.createdAt)); 29 33 34 + const rpc = new XRPC({ 35 + handler: simpleFetchHandler({ 36 + service: 'https://public.api.bsky.app', 37 + }), 38 + }) 39 + 40 + let authorInfo: BlueRecipesFeedDefs.AuthorInfo | null = null; 41 + if (did) { 42 + authorInfo = await getAuthorInfo(did, rpc); 43 + }; 44 + 30 45 const results = []; 31 46 const eachRecipe = async (r: typeof recipes[0]) => ({ 32 - author: await (async () => { 33 - const author = await getDidDoc(r.authorDid); 34 - return author.alsoKnownAs[0]?.substring(5); 35 - })(), 47 + author: authorInfo || await getAuthorInfo(r.authorDid, rpc), 36 48 rkey: r.rkey, 37 - did: r.authorDid, 38 49 title: r.title, 39 - description: r.description, 40 - ingredients: r.ingredientsCount, 41 - steps: r.stepsCount , 50 + time: 5, 51 + description: r.description || undefined, 52 + ingredients: r.ingredientsCount as number, 53 + steps: r.stepsCount as number, 42 54 }); 43 55 44 56 for (const result of recipes) { 45 57 results.push(await eachRecipe(result)); 46 58 } 47 59 48 - return ctx.json({ 60 + let result: BlueRecipesFeedGetRecipes.Output = { 61 + author: authorInfo || undefined, 49 62 recipes: results, 50 - }); 63 + }; 64 + 65 + return ctx.json(result); 51 66 }); 52 67 53 68 xrpcApp.get('/blue.recipes.feed.getRecipe', async ctx => {
+1 -1
apps/web/src/hooks/use-xrpc.tsx
··· 15 15 }); 16 16 } 17 17 18 - const creds = new CredentialManager({ service: `https://${SERVER_URL}` }); 18 + const creds = new CredentialManager({ service: `http://${SERVER_URL}` }); 19 19 return new XRPC({ handler: creds }); 20 20 }
+1 -7
apps/web/src/routes/_.(app)/index.lazy.tsx
··· 50 50 <QueryPlaceholder query={query} cards cardsCount={12}> 51 51 {query.data?.recipes.map((recipe, idx) => ( 52 52 <RecipeCard 53 - title={recipe.title} 54 - description={recipe.description} 55 - rkey={recipe.rkey} 56 - author={recipe.author} 57 - time={{ amount: 30, unit: 'min' }} 58 - steps={recipe.steps} 59 - ingredients={recipe.ingredients} 53 + recipe={recipe} 60 54 key={idx} 61 55 /> 62 56 ))}
+7 -8
apps/web/src/routes/_.(app)/recipes/$author/index.lazy.tsx
··· 12 12 import QueryPlaceholder from '@/components/query-placeholder' 13 13 import { useRecipesQuery } from '@/queries/recipe' 14 14 import { RecipeCard } from '@/screens/Recipes/RecipeCard' 15 + import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' 15 16 16 17 export const Route = createLazyFileRoute('/_/(app)/recipes/$author/')({ 17 18 component: RouteComponent, ··· 49 50 </div> 50 51 </header> 51 52 <div className="flex flex-col gap-4 p-4 pt-6 items-center"> 52 - <h1 className="text-4xl font-black">{author}'s recipes!</h1> 53 + <Avatar className="h-24 w-24 rounded-lg"> 54 + <AvatarImage src={query.data?.author?.avatarUrl} alt={query.data?.author?.displayName} /> 55 + <AvatarFallback className="rounded-lg">{query.data?.author?.displayName}</AvatarFallback> 56 + </Avatar> 57 + <h1 className="text-4xl font-black">{query.data?.author?.displayName}'s recipes!</h1> 53 58 <p className="text-lg">See what they've been cooking.</p> 54 59 </div> 55 60 <div className="flex-1 flex flex-col items-center p-4"> ··· 57 62 <QueryPlaceholder query={query} cards cardsCount={12}> 58 63 {query.data?.recipes.map((recipe, idx) => ( 59 64 <RecipeCard 60 - title={recipe.title} 61 - description={recipe.description} 62 - rkey={recipe.rkey} 63 - author={recipe.author} 64 - time={{ amount: 30, unit: 'min' }} 65 - steps={recipe.steps} 66 - ingredients={recipe.ingredients} 65 + recipe={recipe} 67 66 key={idx} 68 67 /> 69 68 ))}
+14 -15
apps/web/src/screens/Recipes/RecipeCard.tsx
··· 1 + import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; 1 2 import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; 3 + import { BlueRecipesFeedGetRecipes } from "@atcute/client/lexicons"; 2 4 import { Link } from "@tanstack/react-router"; 3 5 import { Clock, CookingPot, ListIcon } from "lucide-react"; 4 6 5 7 type RecipeCardProps = { 6 - rkey: string; 7 - author: string; 8 - 9 - title: string; 10 - description?: string; 11 - steps: number; 12 - ingredients: number; 13 - time: { 14 - amount: number; 15 - unit: string; 16 - }; 8 + recipe: BlueRecipesFeedGetRecipes.Result; 17 9 }; 18 10 19 - export const RecipeCard = ({ rkey, author, ...recipe }: RecipeCardProps) => { 11 + export const RecipeCard = ({ recipe }: RecipeCardProps) => { 20 12 return ( 21 - <Link to="/recipes/$author/$rkey" params={{ author, rkey }} className="w-full"> 13 + <Link to="/recipes/$author/$rkey" params={{ author: recipe.author.handle, rkey: recipe.rkey }} className="w-full"> 22 14 <Card className="w-full"> 23 15 <CardHeader> 16 + <CardDescription className="flex items-center space-x-2"> 17 + <Avatar className="h-6 w-6 rounded-lg"> 18 + <AvatarImage src={recipe.author.avatarUrl} alt={recipe.author.displayName} /> 19 + <AvatarFallback className="rounded-lg">{recipe.author.displayName}</AvatarFallback> 20 + </Avatar> 21 + 22 + <span>{recipe.author.displayName}</span> 23 + </CardDescription> 24 24 <CardTitle>{recipe.title}</CardTitle> 25 - <CardDescription>By @{author}</CardDescription> 26 25 </CardHeader> 27 26 <CardContent> 28 27 <p>{recipe.description}</p> ··· 37 36 </span> 38 37 39 38 <span className="flex items-center gap-2"> 40 - <Clock className="size-4" /> <span>{`${recipe.time.amount} ${recipe.time.unit}`}</span> 39 + <Clock className="size-4" /> <span>{recipe.time} mins</span> 41 40 </span> 42 41 </CardFooter> 43 42 </Card>
+7 -17
lexicons/blue/recipes/feed/defs.json
··· 12 12 "description": "The name of the ingredient." 13 13 }, 14 14 "amount": { 15 - "type": "integer", 16 - "description": "How much of the ingredient is needed." 17 - }, 18 - "unit": { 19 15 "type": "string", 20 - "maxLength": 3000, 21 - "maxGraphemes": 300, 22 - "description": "The unit the ingredient is measured in." 16 + "description": "How much of the ingredient is needed." 23 17 } 24 18 } 25 19 }, ··· 35 29 } 36 30 } 37 31 }, 38 - "elapsedTime": { 32 + "authorInfo": { 39 33 "type": "object", 40 - "required": ["amount", "unit"], 34 + "required": ["did", "handle"], 41 35 "properties": { 42 - "amount": { 43 - "type": "integer", 44 - "description": "The amount of (#unit) to display." 45 - }, 46 - "unit": { 47 - "type": "string", 48 - "description": "The unit to display the time in." 49 - } 36 + "did": { "type": "string" }, 37 + "handle": { "type": "string" }, 38 + "displayName": { "type": "string" }, 39 + "avatarUrl": { "type": "string" } 50 40 } 51 41 } 52 42 }
+2 -11
lexicons/blue/recipes/feed/getRecipe.json
··· 36 36 "type": "object", 37 37 "required": ["author", "title", "ingredients", "steps"], 38 38 "properties": { 39 - "author": { 40 - "type": "ref", 41 - "ref": "#authorInfo" 42 - }, 39 + "author": { "type": "ref", "ref": "blue.recipes.feed.defs#authorInfo" }, 43 40 "title": { "type": "string" }, 44 41 "description": { "type": "string" }, 42 + "time": { "type": "integer" }, 45 43 "ingredients": { 46 44 "type": "array", 47 45 "items": { ··· 56 54 "ref": "blue.recipes.feed.defs#step" 57 55 } 58 56 } 59 - } 60 - }, 61 - "authorInfo": { 62 - "type": "object", 63 - "required": ["handle"], 64 - "properties": { 65 - "handle": { "type": "string" } 66 57 } 67 58 } 68 59 }
+7 -3
lexicons/blue/recipes/feed/getRecipes.json
··· 24 24 "type": "object", 25 25 "required": ["recipes"], 26 26 "properties": { 27 + "author": { 28 + "type": "ref", 29 + "ref": "blue.recipes.feed.defs#authorInfo" 30 + }, 27 31 "recipes": { 28 32 "type": "array", 29 33 "items": { ··· 37 41 }, 38 42 "result": { 39 43 "type": "object", 40 - "required": [ "author", "rkey", "did", "title", "ingredients", "steps"], 44 + "required": [ "rkey", "author", "title", "time", "ingredients", "steps"], 41 45 "properties": { 42 - "author": { "type": "string" }, 43 46 "rkey": { "type": "string" }, 47 + "author": { "type": "ref", "ref": "blue.recipes.feed.defs#authorInfo" }, 44 48 "type": { "type": "string" }, 45 - "did": { "type": "string" }, 46 49 "title": { "type": "string" }, 50 + "time": { "type": "integer" }, 47 51 "description": { "type": "string" }, 48 52 "ingredients": { "type": "integer" }, 49 53 "steps": { "type": "integer" }
+3 -3
lexicons/blue/recipes/feed/recipes.json
··· 22 22 "maxGraphemes": 300, 23 23 "description": "The description of the recipe." 24 24 }, 25 - "estimate": { 26 - "type": "ref", 27 - "ref": "blue.recipes.feed.defs#elapsedTime" 25 + "time": { 26 + "type": "integer", 27 + "description": "The amount of time (in minutes) the recipe takes to complete." 28 28 }, 29 29 "ingredients": { 30 30 "type": "array",
+14 -21
libs/lexicons/src/atcute.ts
··· 10 10 11 11 declare module "@atcute/client/lexicons" { 12 12 namespace BlueRecipesFeedDefs { 13 - interface ElapsedTime { 14 - [Brand.Type]?: "blue.recipes.feed.defs#elapsedTime"; 15 - /** The amount of (#unit) to display. */ 16 - amount: number; 17 - /** The unit to display the time in. */ 18 - unit: string; 13 + interface AuthorInfo { 14 + [Brand.Type]?: "blue.recipes.feed.defs#authorInfo"; 15 + did: string; 16 + handle: string; 17 + avatarUrl?: string; 18 + displayName?: string; 19 19 } 20 20 interface Ingredient { 21 21 [Brand.Type]?: "blue.recipes.feed.defs#ingredient"; 22 22 /** How much of the ingredient is needed. */ 23 - amount?: number; 23 + amount?: string; 24 24 /** 25 25 * The name of the ingredient. \ 26 26 * Maximum string length: 3000 \ 27 27 * Maximum grapheme length: 300 28 28 */ 29 29 name?: string; 30 - /** 31 - * The unit the ingredient is measured in. \ 32 - * Maximum string length: 3000 \ 33 - * Maximum grapheme length: 300 34 - */ 35 - unit?: string; 36 30 } 37 31 interface Step { 38 32 [Brand.Type]?: "blue.recipes.feed.defs#step"; ··· 55 49 interface Output { 56 50 recipe: Result; 57 51 } 58 - interface AuthorInfo { 59 - [Brand.Type]?: "blue.recipes.feed.getRecipe#authorInfo"; 60 - handle: string; 61 - } 62 52 interface Result { 63 53 [Brand.Type]?: "blue.recipes.feed.getRecipe#result"; 64 - author: AuthorInfo; 54 + author: BlueRecipesFeedDefs.AuthorInfo; 65 55 ingredients: BlueRecipesFeedDefs.Ingredient[]; 66 56 steps: BlueRecipesFeedDefs.Step[]; 67 57 title: string; 68 58 description?: string; 59 + time?: number; 69 60 } 70 61 } 71 62 ··· 78 69 type Input = undefined; 79 70 interface Output { 80 71 recipes: Result[]; 72 + author?: BlueRecipesFeedDefs.AuthorInfo; 81 73 } 82 74 interface Result { 83 75 [Brand.Type]?: "blue.recipes.feed.getRecipes#result"; 84 - author: string; 85 - did: string; 76 + author: BlueRecipesFeedDefs.AuthorInfo; 86 77 ingredients: number; 87 78 rkey: string; 88 79 steps: number; 80 + time: number; 89 81 title: string; 90 82 description?: string; 91 83 type?: string; ··· 110 102 * Maximum grapheme length: 300 111 103 */ 112 104 description?: string; 113 - estimate?: BlueRecipesFeedDefs.ElapsedTime; 105 + /** The amount of time (in minutes) the recipe takes to complete. */ 106 + time?: number; 114 107 } 115 108 } 116 109
+7 -4
pnpm-lock.yaml
··· 52 52 version: 4.0.8 53 53 drizzle-orm: 54 54 specifier: ^0.37.0 55 - version: 0.37.0(@libsql/client@0.14.0(bufferutil@4.0.8))(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.0.1)(react@19.0.0) 55 + version: 0.37.0(@libsql/client@0.14.0)(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.0.1)(react@19.0.0) 56 56 hono: 57 57 specifier: ^4.6.12 58 58 version: 4.6.12 ··· 75 75 specifier: ^3.23.8 76 76 version: 3.23.8 77 77 devDependencies: 78 + '@atcute/bluesky': 79 + specifier: ^1.0.9 80 + version: 1.0.9(@atcute/client@2.0.6) 78 81 '@cookware/lexicons': 79 82 specifier: workspace:* 80 83 version: link:../../libs/lexicons ··· 131 134 version: 4.0.8 132 135 drizzle-orm: 133 136 specifier: ^0.37.0 134 - version: 0.37.0(@libsql/client@0.14.0(bufferutil@4.0.8))(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.0.1)(react@19.0.0) 137 + version: 0.37.0(@libsql/client@0.14.0)(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.0.1)(react@19.0.0) 135 138 pino: 136 139 specifier: ^9.5.0 137 140 version: 9.5.0 ··· 340 343 version: 0.14.0(bufferutil@4.0.8) 341 344 drizzle-orm: 342 345 specifier: ^0.37.0 343 - version: 0.37.0(@libsql/client@0.14.0(bufferutil@4.0.8))(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.0.1)(react@19.0.0) 346 + version: 0.37.0(@libsql/client@0.14.0)(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.0.1)(react@19.0.0) 344 347 zod: 345 348 specifier: ^3.23.8 346 349 version: 3.23.8 ··· 6671 6674 transitivePeerDependencies: 6672 6675 - supports-color 6673 6676 6674 - drizzle-orm@0.37.0(@libsql/client@0.14.0(bufferutil@4.0.8))(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.0.1)(react@19.0.0): 6677 + drizzle-orm@0.37.0(@libsql/client@0.14.0)(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.0.1)(react@19.0.0): 6675 6678 optionalDependencies: 6676 6679 '@libsql/client': 0.14.0(bufferutil@4.0.8) 6677 6680 '@opentelemetry/api': 1.9.0