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: image ingesting

+223 -12
+2
apps/ingester/src/index.ts
··· 33 33 description: record.description, 34 34 ingredients: record.ingredients, 35 35 steps: record.steps, 36 + imageRef: record.image ? record.image.ref.$link : null, 36 37 authorDid: parseDid(event.did)!, 37 38 createdAt: now, 38 39 }) ··· 44 45 description: record.description, 45 46 ingredients: record.ingredients, 46 47 steps: record.steps, 48 + imageRef: record.image ? record.image.ref.$link : null, 47 49 }, 48 50 }) 49 51 .execute();
+9
apps/web/src/forms/recipe.ts
··· 1 + import { RecipeRecord } from "@cookware/lexicons"; 2 + import { z } from "zod"; 3 + 4 + export const recipeSchema = RecipeRecord.extend({ 5 + time: z.coerce.number(), 6 + image: z 7 + .instanceof(FileList) 8 + .or(z.null()), 9 + });
+18 -4
apps/web/src/queries/recipe.ts
··· 1 1 import { useXrpc } from "@/hooks/use-xrpc"; 2 2 import { useAuth } from "@/state/auth"; 3 3 import { XRPC, XRPCError } from "@atcute/client"; 4 - import { Recipe, RecipeCollection } from "@cookware/lexicons"; 4 + import { RecipeCollection } from "@cookware/lexicons"; 5 5 import { queryOptions, useMutation, useQuery } from "@tanstack/react-query"; 6 6 import { notFound } from "@tanstack/react-router"; 7 7 import { UseFormReturn } from "react-hook-form"; 8 8 import { TID } from '@atproto/common-web'; 9 + import { recipeSchema } from "@/forms/recipe"; 10 + import { z } from "zod"; 9 11 10 12 const RQKEY_ROOT = 'posts'; 11 13 export const RQKEY = (cursor: string, did: string, rkey: string) => [RQKEY_ROOT, cursor, did, rkey]; ··· 47 49 return useQuery(recipeQueryOptions(rpc, did, rkey)); 48 50 }; 49 51 50 - export const useNewRecipeMutation = (form: UseFormReturn<Recipe>) => { 52 + export const useNewRecipeMutation = (form: UseFormReturn<z.infer<typeof recipeSchema>>) => { 51 53 const { agent } = useAuth(); 52 54 const rpc = useXrpc(); 53 55 return useMutation({ 54 56 mutationKey: ['recipes.new'], 55 - mutationFn: async ({ recipe }: { recipe: Recipe }) => { 57 + mutationFn: async ({ recipe: { image, ...recipe } }: { recipe: z.infer<typeof recipeSchema> }) => { 58 + let recipeImg = null; 59 + if (image) { 60 + const imageFile = image.item(0) as File; 61 + const res = await rpc.call('com.atproto.repo.uploadBlob', { 62 + data: imageFile, 63 + }); 64 + recipeImg = res.data.blob 65 + } 66 + 56 67 const rkey = TID.nextStr(); 57 68 const res = await rpc.call(`com.atproto.repo.createRecord`, { 58 69 data: { 59 70 repo: agent?.session.info.sub as `did:${string}`, 60 - record: recipe, 71 + record: { 72 + ...recipe, 73 + image: recipeImg, 74 + }, 61 75 collection: RecipeCollection, 62 76 rkey: rkey, 63 77 },
+25 -8
apps/web/src/routes/_.(app)/recipes/new.tsx
··· 12 12 import { useFieldArray, useForm } from "react-hook-form"; 13 13 import { z } from "zod"; 14 14 import { zodResolver } from "@hookform/resolvers/zod"; 15 - import { RecipeRecord } from "@cookware/lexicons"; 16 15 import { 17 16 Form, 18 17 FormControl, ··· 41 40 import { Label } from "@/components/ui/label"; 42 41 import { TrashIcon } from "lucide-react"; 43 42 import { useNewRecipeMutation } from "@/queries/recipe"; 43 + import { recipeSchema } from "@/forms/recipe"; 44 44 45 45 export const Route = createFileRoute("/_/(app)/recipes/new")({ 46 46 beforeLoad: async ({ context }) => { ··· 53 53 component: RouteComponent, 54 54 }); 55 55 56 - const schema = RecipeRecord.extend({ 57 - time: z.coerce.number(), 58 - }); 59 - 60 56 function RouteComponent() { 61 - const form = useForm<z.infer<typeof schema>>({ 62 - resolver: zodResolver(schema), 57 + const form = useForm<z.infer<typeof recipeSchema>>({ 58 + resolver: zodResolver(recipeSchema), 63 59 defaultValues: { 64 60 title: "", 65 61 time: 0, 62 + image: null, 66 63 description: "", 67 64 ingredients: [{ name: "" }], 68 65 steps: [{ text: "" }], ··· 71 68 72 69 const { mutate, isPending } = useNewRecipeMutation(form); 73 70 74 - const onSubmit = (values: z.infer<typeof schema>) => { 71 + const onSubmit = (values: z.infer<typeof recipeSchema>) => { 75 72 mutate({ recipe: values }); 76 73 }; 74 + 75 + const imageRef = form.register("image"); 77 76 78 77 const ingredients = useFieldArray({ 79 78 control: form.control, ··· 131 130 /> 132 131 </FormControl> 133 132 <FormDescription>Describe your recipe, maybe tell the world how tasty it is? (Optional)</FormDescription> 133 + <FormMessage /> 134 + </FormItem> 135 + )} 136 + /> 137 + 138 + <FormField 139 + name="image" 140 + control={form.control} 141 + render={(_props) => ( 142 + <FormItem> 143 + <FormLabel>Image</FormLabel> 144 + <FormControl> 145 + <Input 146 + type="file" 147 + className="resize-none" 148 + {...imageRef} 149 + /> 150 + </FormControl> 134 151 <FormMessage /> 135 152 </FormItem> 136 153 )}
+5
libs/database/migrations/0002_redundant_wither.sql
··· 1 + DROP INDEX IF EXISTS "recipes_id_unique";--> statement-breakpoint 2 + DROP INDEX IF EXISTS "recipes_rkey_author_did_unique";--> statement-breakpoint 3 + ALTER TABLE `recipes` ALTER COLUMN "title" TO "title" text;--> statement-breakpoint 4 + CREATE UNIQUE INDEX `recipes_id_unique` ON `recipes` (`id`);--> statement-breakpoint 5 + CREATE UNIQUE INDEX `recipes_rkey_author_did_unique` ON `recipes` (`rkey`,`author_did`);
+156
libs/database/migrations/meta/0002_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "5e0d9054-f192-4db9-8afe-afcabe08e661", 5 + "prevId": "2c6fca6c-38c3-4482-b189-6defabb5f8c8", 6 + "tables": { 7 + "auth_session": { 8 + "name": "auth_session", 9 + "columns": { 10 + "key": { 11 + "name": "key", 12 + "type": "text", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "session": { 18 + "name": "session", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + } 24 + }, 25 + "indexes": {}, 26 + "foreignKeys": {}, 27 + "compositePrimaryKeys": {}, 28 + "uniqueConstraints": {}, 29 + "checkConstraints": {} 30 + }, 31 + "auth_state": { 32 + "name": "auth_state", 33 + "columns": { 34 + "key": { 35 + "name": "key", 36 + "type": "text", 37 + "primaryKey": true, 38 + "notNull": true, 39 + "autoincrement": false 40 + }, 41 + "state": { 42 + "name": "state", 43 + "type": "text", 44 + "primaryKey": false, 45 + "notNull": true, 46 + "autoincrement": false 47 + } 48 + }, 49 + "indexes": {}, 50 + "foreignKeys": {}, 51 + "compositePrimaryKeys": {}, 52 + "uniqueConstraints": {}, 53 + "checkConstraints": {} 54 + }, 55 + "recipes": { 56 + "name": "recipes", 57 + "columns": { 58 + "id": { 59 + "name": "id", 60 + "type": "integer", 61 + "primaryKey": true, 62 + "notNull": true, 63 + "autoincrement": false 64 + }, 65 + "rkey": { 66 + "name": "rkey", 67 + "type": "text", 68 + "primaryKey": false, 69 + "notNull": true, 70 + "autoincrement": false 71 + }, 72 + "title": { 73 + "name": "title", 74 + "type": "text", 75 + "primaryKey": false, 76 + "notNull": false, 77 + "autoincrement": false 78 + }, 79 + "time": { 80 + "name": "time", 81 + "type": "integer", 82 + "primaryKey": false, 83 + "notNull": true, 84 + "autoincrement": false, 85 + "default": 0 86 + }, 87 + "description": { 88 + "name": "description", 89 + "type": "text", 90 + "primaryKey": false, 91 + "notNull": false, 92 + "autoincrement": false 93 + }, 94 + "ingredients": { 95 + "name": "ingredients", 96 + "type": "text", 97 + "primaryKey": false, 98 + "notNull": true, 99 + "autoincrement": false 100 + }, 101 + "steps": { 102 + "name": "steps", 103 + "type": "text", 104 + "primaryKey": false, 105 + "notNull": true, 106 + "autoincrement": false 107 + }, 108 + "created_at": { 109 + "name": "created_at", 110 + "type": "text", 111 + "primaryKey": false, 112 + "notNull": true, 113 + "autoincrement": false 114 + }, 115 + "author_did": { 116 + "name": "author_did", 117 + "type": "text", 118 + "primaryKey": false, 119 + "notNull": true, 120 + "autoincrement": false 121 + } 122 + }, 123 + "indexes": { 124 + "recipes_id_unique": { 125 + "name": "recipes_id_unique", 126 + "columns": [ 127 + "id" 128 + ], 129 + "isUnique": true 130 + }, 131 + "recipes_rkey_author_did_unique": { 132 + "name": "recipes_rkey_author_did_unique", 133 + "columns": [ 134 + "rkey", 135 + "author_did" 136 + ], 137 + "isUnique": true 138 + } 139 + }, 140 + "foreignKeys": {}, 141 + "compositePrimaryKeys": {}, 142 + "uniqueConstraints": {}, 143 + "checkConstraints": {} 144 + } 145 + }, 146 + "views": {}, 147 + "enums": {}, 148 + "_meta": { 149 + "schemas": {}, 150 + "tables": {}, 151 + "columns": {} 152 + }, 153 + "internal": { 154 + "indexes": {} 155 + } 156 + }
+7
libs/database/migrations/meta/_journal.json
··· 15 15 "when": 1734630004978, 16 16 "tag": "0001_icy_killmonger", 17 17 "breakpoints": true 18 + }, 19 + { 20 + "idx": 2, 21 + "version": "6", 22 + "when": 1734642833768, 23 + "tag": "0002_redundant_wither", 24 + "breakpoints": true 18 25 } 19 26 ] 20 27 }
+1
libs/database/src/schema.ts
··· 21 21 id: int('id').primaryKey().notNull().unique(), 22 22 rkey: text('rkey').notNull(), 23 23 title: text('title').notNull(), 24 + imageRef: text('title'), 24 25 time: int('time').notNull().default(0), 25 26 description: text('description'), 26 27 ingredients: text('ingredients', { mode: 'json' }).$type<Partial<Ingredient>[]>().notNull(),