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: new image recipe card

+115 -6
+65
apps/web/src/components/recipe-card.tsx
··· 1 + import { BlueRecipesFeedGetRecipes } from "@atcute/client/lexicons"; 2 + import { Card, CardContent, CardFooter, CardHeader } from "./ui/card"; 3 + import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; 4 + import { Link } from "@tanstack/react-router"; 5 + import { Clock, ListOrdered, Utensils } from "lucide-react"; 6 + 7 + type RecipeCardProps = { 8 + recipe: BlueRecipesFeedGetRecipes.Result; 9 + }; 10 + 11 + function truncateDescription(description: string, maxLength: number = 120) { 12 + if (description.length <= maxLength) return description; 13 + return description.slice(0, maxLength).trim() + '...'; 14 + } 15 + 16 + export const RecipeCard = ({ recipe }: RecipeCardProps) => { 17 + return ( 18 + <Link to="/recipes/$author/$rkey" params={{ author: recipe.author.handle, rkey: recipe.rkey }} className="w-full"> 19 + <Card className="overflow-hidden"> 20 + <CardHeader className="p-0"> 21 + <div className="relative h-48 w-full"> 22 + <img 23 + src={"https://www.foodandwine.com/thmb/fjNakOY7IcuvZac1hR3JcSo7vzI=/1500x0/filters:no_upscale():max_bytes(150000):strip_icc()/FAW-recipes-pasta-sausage-basil-and-mustard-hero-06-cfd1c0a2989e474ea7e574a38182bbee.jpg"} 24 + alt={recipe.title} 25 + className="h-full w-full object-cover" 26 + /> 27 + </div> 28 + </CardHeader> 29 + <CardContent className="p-4"> 30 + <h3 className="text-lg font-semibold mb-2">{recipe.title}</h3> 31 + <p className="text-sm text-muted-foreground mb-4"> 32 + {truncateDescription(recipe.description || '')} 33 + </p> 34 + </CardContent> 35 + <CardFooter className="p-4 pt-0"> 36 + <div className="w-full flex items-center justify-between"> 37 + <div className="flex items-center"> 38 + <Avatar className="h-8 w-8 mr-2"> 39 + <AvatarImage src={recipe.author.avatarUrl} alt={recipe.author.displayName} /> 40 + <AvatarFallback className="rounded-lg">{recipe.author.displayName?.charAt(0)}</AvatarFallback> 41 + </Avatar> 42 + <span className="text-sm text-muted-foreground">{recipe.author.displayName}</span> 43 + </div> 44 + <div className="flex gap-6 justify-between items-center text-sm text-muted-foreground"> 45 + <div className="flex items-center"> 46 + <Utensils className="w-4 h-4 mr-1" /> 47 + <span>{recipe.ingredients}</span> 48 + </div> 49 + 50 + <div className="flex items-center"> 51 + <ListOrdered className="w-4 h-4 mr-1" /> 52 + <span>{recipe.steps}</span> 53 + </div> 54 + 55 + <div className="flex items-center"> 56 + <Clock className="w-4 h-4 mr-1" /> 57 + <span>{recipe.time} min</span> 58 + </div> 59 + </div> 60 + </div> 61 + </CardFooter> 62 + </Card> 63 + </Link> 64 + ); 65 + };
+36
apps/web/src/components/ui/badge.tsx
··· 1 + import * as React from "react" 2 + import { cva, type VariantProps } from "class-variance-authority" 3 + 4 + import { cn } from "@/lib/utils" 5 + 6 + const badgeVariants = cva( 7 + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 + { 9 + variants: { 10 + variant: { 11 + default: 12 + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", 13 + secondary: 14 + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 + destructive: 16 + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", 17 + outline: "text-foreground", 18 + }, 19 + }, 20 + defaultVariants: { 21 + variant: "default", 22 + }, 23 + } 24 + ) 25 + 26 + export interface BadgeProps 27 + extends React.HTMLAttributes<HTMLDivElement>, 28 + VariantProps<typeof badgeVariants> {} 29 + 30 + function Badge({ className, variant, ...props }: BadgeProps) { 31 + return ( 32 + <div className={cn(badgeVariants({ variant }), className)} {...props} /> 33 + ) 34 + } 35 + 36 + export { Badge, badgeVariants }
+1 -1
apps/web/src/routes/_.(app)/index.lazy.tsx
··· 11 11 import { SidebarTrigger } from '@/components/ui/sidebar' 12 12 import QueryPlaceholder from '@/components/query-placeholder' 13 13 import { useRecipesQuery } from '@/queries/recipe' 14 - import { RecipeCard } from '@/screens/Recipes/RecipeCard' 14 + import { RecipeCard } from '@/components/recipe-card'; 15 15 16 16 export const Route = createLazyFileRoute('/_/(app)/')({ 17 17 component: RouteComponent,
+1 -1
apps/web/src/routes/_.(app)/recipes/$author/index.lazy.tsx
··· 11 11 import { SidebarTrigger } from '@/components/ui/sidebar' 12 12 import QueryPlaceholder from '@/components/query-placeholder' 13 13 import { useRecipesQuery } from '@/queries/recipe' 14 - import { RecipeCard } from '@/screens/Recipes/RecipeCard' 14 + import { RecipeCard } from '@/components/recipe-card' 15 15 import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' 16 16 17 17 export const Route = createLazyFileRoute('/_/(app)/recipes/$author/')({
+2 -4
apps/web/src/routes/_.(app)/recipes/new.tsx
··· 41 41 import { Label } from "@/components/ui/label"; 42 42 import { TrashIcon } from "lucide-react"; 43 43 import { useNewRecipeMutation } from "@/queries/recipe"; 44 + import { useState } from "react"; 44 45 45 46 export const Route = createFileRoute("/_/(app)/recipes/new")({ 46 - beforeLoad: async ({ context, location }) => { 47 + beforeLoad: async ({ context }) => { 47 48 if (!context.auth.isLoggedIn) { 48 49 throw redirect({ 49 50 to: '/login', 50 - search: { 51 - redirect: location.href, 52 - }, 53 51 }); 54 52 } 55 53 },
+1
lexicons/blue/recipes/feed/getRecipe.json
··· 40 40 "title": { "type": "string" }, 41 41 "description": { "type": "string" }, 42 42 "time": { "type": "integer" }, 43 + "imageUrl": { "type": "string" }, 43 44 "ingredients": { 44 45 "type": "array", 45 46 "items": {
+1
lexicons/blue/recipes/feed/getRecipes.json
··· 46 46 "rkey": { "type": "string" }, 47 47 "author": { "type": "ref", "ref": "blue.recipes.feed.defs#authorInfo" }, 48 48 "type": { "type": "string" }, 49 + "imageUrl": { "type": "string" }, 49 50 "title": { "type": "string" }, 50 51 "time": { "type": "integer" }, 51 52 "description": { "type": "string" },
+6
lexicons/blue/recipes/feed/recipes.json
··· 22 22 "maxGraphemes": 300, 23 23 "description": "The description of the recipe." 24 24 }, 25 + "image": { 26 + "type": "blob", 27 + "description": "The recipe's cover image.", 28 + "accept": ["image/*"], 29 + "maxSize": 1000000 30 + }, 25 31 "time": { 26 32 "type": "integer", 27 33 "description": "The amount of time (in minutes) the recipe takes to complete."
+2
libs/lexicons/src/atcute.ts
··· 102 102 * Maximum grapheme length: 300 103 103 */ 104 104 description?: string; 105 + /** The recipe's cover image. */ 106 + image?: At.Blob; 105 107 /** The amount of time (in minutes) the recipe takes to complete. */ 106 108 time?: number; 107 109 }