+5
-2
apps/api/src/xrpc/blue.recipes.feed.getRecipe.ts
+5
-2
apps/api/src/xrpc/blue.recipes.feed.getRecipe.ts
···
1
1
import { json, XRPCRouter, XRPCError } from '@atcute/xrpc-server';
2
-
import { BlueRecipesFeedGetRecipe, BlueRecipesFeedRecipe } from '@cookware/lexicons';
2
+
import { BlueRecipesFeedDefs, BlueRecipesFeedGetRecipe, BlueRecipesFeedRecipe } from '@cookware/lexicons';
3
3
import { db, and, or, eq } from '@cookware/database';
4
4
import { buildProfileViewBasic, parseDid } from '../util/api.js';
5
5
import { Logger } from 'pino';
···
7
7
import { recipeTable } from '@cookware/database/schema';
8
8
import { isLegacyBlob } from '@atcute/lexicons/interfaces';
9
9
import { RedisClient } from 'bun';
10
+
import { buildCdnUrl } from '../util/cdn.js';
10
11
11
12
const invalidUriError = (uri: string) => new XRPCError({
12
13
status: 400,
···
55
56
uri: recipe.uri as ResourceUri,
56
57
author: await buildProfileViewBasic(author, redis),
57
58
cid: recipe.cid,
59
+
rkey: recipe.rkey,
60
+
imageUrl: recipe.imageRef ? buildCdnUrl('post_image', recipe.did, recipe.imageRef) : undefined,
58
61
indexedAt: recipe.ingestedAt.toISOString(),
59
62
record: {
60
63
$type: BlueRecipesFeedRecipe.mainSchema.object.shape.$type.expected,
···
67
70
image: isLegacyBlob(recipe.imageRef) ? undefined : recipe.imageRef ?? undefined,
68
71
createdAt: recipe.createdAt.toISOString(),
69
72
},
70
-
}))
73
+
})),
71
74
),
72
75
});
73
76
},
+1
apps/api/src/xrpc/blue.recipes.feed.getRecipes.ts
+1
apps/api/src/xrpc/blue.recipes.feed.getRecipes.ts
+7
-3
apps/web/src/components/query-placeholder.tsx
+7
-3
apps/web/src/components/query-placeholder.tsx
···
1
1
import type { UseQueryResult } from '@tanstack/react-query';
2
-
import { PropsWithChildren, ReactNode } from 'react';
2
+
import { ReactNode } from 'react';
3
3
import { Skeleton } from './ui/skeleton';
4
4
import { Alert, AlertDescription, AlertTitle } from './ui/alert';
5
5
import { AlertCircle } from 'lucide-react';
6
6
import { isXRPCErrorPayload } from '@atcute/client';
7
7
8
-
type QueryPlaceholderProps<TData, TError> = PropsWithChildren<{
8
+
type QueryPlaceholderProps<TData, TError> = {
9
9
query: UseQueryResult<TData, TError>;
10
10
cards?: boolean;
11
11
cardsCount?: number;
12
12
noData?: ReactNode;
13
-
}>;
13
+
children: ReactNode | ReactNode[] | ((data: TData) => ReactNode | ReactNode[]);
14
+
};
14
15
15
16
const QueryPlaceholder = <TData = {}, TError = Error>(
16
17
{
···
50
51
</Alert>
51
52
)
52
53
} else if (query.data) {
54
+
if (typeof children === 'function') {
55
+
return children(query.data);
56
+
}
53
57
return children;
54
58
}
55
59
return noData;
+11
-11
apps/web/src/components/recipe-card.tsx
+11
-11
apps/web/src/components/recipe-card.tsx
···
1
-
import { BlueRecipesFeedGetRecipes } from "@atcute/client/lexicons";
2
1
import { Card, CardContent, CardFooter, CardHeader } from "./ui/card";
3
2
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
4
3
import { Link } from "@tanstack/react-router";
5
4
import { Clock, ListOrdered, Users, Utensils } from "lucide-react";
5
+
import { BlueRecipesFeedGetRecipes } from "@cookware/lexicons";
6
6
7
7
type RecipeCardProps = {
8
-
recipe: BlueRecipesFeedGetRecipes.Result;
8
+
recipe: BlueRecipesFeedGetRecipes.$output['recipes'][0];
9
9
};
10
10
11
11
function truncateDescription(description: string, maxLength: number = 120) {
···
18
18
<Link to="/recipes/$author/$rkey" params={{ author: recipe.author.handle, rkey: recipe.rkey }} className="w-full">
19
19
<Card className="overflow-hidden">
20
20
<CardHeader className="p-0">
21
-
{ recipe.imageUrl &&
21
+
{ recipe.record.image &&
22
22
<div className="relative h-48 w-full">
23
23
<img
24
24
src={recipe.imageUrl}
25
-
alt={recipe.title}
25
+
alt={recipe.record.title}
26
26
className="h-full w-full object-cover"
27
27
/>
28
28
</div>
29
29
}
30
30
</CardHeader>
31
31
<CardContent className="p-4">
32
-
<h3 className="text-lg font-semibold mb-2">{recipe.title}</h3>
32
+
<h3 className="text-lg font-semibold mb-2">{recipe.record.title}</h3>
33
33
<p className="text-sm text-muted-foreground mb-4">
34
-
{truncateDescription(recipe.description || '')}
34
+
{truncateDescription(recipe.record.description || '')}
35
35
</p>
36
36
</CardContent>
37
37
<CardFooter className="p-4 pt-0">
38
38
<div className="w-full flex items-center justify-between">
39
39
<div className="flex items-center">
40
40
<Avatar className="h-8 w-8 mr-2">
41
-
<AvatarImage src={recipe.author.avatarUrl} alt={recipe.author.displayName} />
41
+
<AvatarImage src={recipe.author.avatar} alt={recipe.author.displayName} />
42
42
<AvatarFallback className="rounded-lg">{recipe.author.displayName?.charAt(0)}</AvatarFallback>
43
43
</Avatar>
44
44
<span className="text-sm text-muted-foreground">{recipe.author.displayName}</span>
···
46
46
<div className="flex gap-6 justify-between items-center text-sm text-muted-foreground">
47
47
<div className="flex items-center">
48
48
<Utensils className="w-4 h-4 mr-1" />
49
-
<span>{recipe.ingredients}</span>
49
+
<span>{recipe.record.ingredients.length}</span>
50
50
</div>
51
51
52
52
<div className="flex items-center">
53
53
<ListOrdered className="w-4 h-4 mr-1" />
54
-
<span>{recipe.steps}</span>
54
+
<span>{recipe.record.steps.length}</span>
55
55
</div>
56
56
57
57
<div className="flex items-center">
58
58
<Users className="w-4 h-4 mr-1" />
59
-
<span>{recipe.serves}</span>
59
+
<span>{recipe.record.serves}</span>
60
60
</div>
61
61
62
62
<div className="flex items-center">
63
63
<Clock className="w-4 h-4 mr-1" />
64
-
<span>{recipe.time} min</span>
64
+
<span>{recipe.record.time} min</span>
65
65
</div>
66
66
</div>
67
67
</div>
+6
-8
apps/web/src/queries/recipe.ts
+6
-8
apps/web/src/queries/recipe.ts
···
1
-
import { useXrpc } from "@/hooks/use-xrpc";
2
-
import { useAuth } from "@/state/auth";
3
1
import { queryOptions, useMutation, useQuery } from "@tanstack/react-query";
4
2
import { Client } from "@atcute/client";
5
3
import { notFound } from "@tanstack/react-router";
···
23
21
const res = await client.get('blue.recipes.feed.getRecipes', {
24
22
params: { cursor, did },
25
23
});
24
+
if (!res.ok) throw res.data;
26
25
return res.data;
27
26
},
28
27
});
29
28
};
30
29
31
-
export const recipeQueryOptions = (rpc: Client, did: Did, rkey: string) => {
30
+
export const recipeQueryOptions = (rpc: Client, actor: ActorIdentifier, rkey: string) => {
32
31
return queryOptions({
33
-
queryKey: RQKEY('', did, rkey),
32
+
queryKey: RQKEY('', actor, rkey),
34
33
queryFn: async () => {
35
34
const { ok, data } = await rpc.get('blue.recipes.feed.getRecipe', {
36
-
params: { did, rkey },
35
+
params: { uris: [`at://${actor}/blue.recipes.feed.recipe/${rkey}`] },
37
36
});
38
37
39
38
if (!ok) {
···
51
50
};
52
51
53
52
export const useRecipeQuery = (did: Did, rkey: string) => {
54
-
const rpc = useXrpc();
53
+
const rpc = useClient();
55
54
return useQuery(recipeQueryOptions(rpc, did, rkey));
56
55
};
57
56
58
57
export const useNewRecipeMutation = (form: UseFormReturn<z.infer<typeof recipeSchema>>) => {
59
-
const { agent } = useAuth();
60
-
const rpc = useXrpc();
58
+
const rpc = useClient();
61
59
return useMutation({
62
60
mutationKey: ['recipes.new'],
63
61
mutationFn: async ({ recipe: { image, ...recipe } }: { recipe: z.infer<typeof recipeSchema> }) => {
+7
-11
apps/web/src/queries/self.ts
+7
-11
apps/web/src/queries/self.ts
···
1
-
import { useXrpc } from "@/hooks/use-xrpc";
2
-
import { useAuth } from "@/state/auth";
3
-
import { AppBskyActorProfile } from "@atcute/client/lexicons";
4
-
import { At } from "@atcute/client/lexicons";
1
+
import { useClient, useSession } from "@/state/auth";
2
+
import { BlueRecipesActorDefs } from "@cookware/lexicons";
5
3
import { useQuery } from "@tanstack/react-query";
6
4
7
5
export const useUserQuery = () => {
8
-
const { isLoggedIn, agent } = useAuth();
9
-
const rpc = useXrpc();
6
+
const { isLoggedIn, agent } = useSession();
7
+
const rpc = useClient();
10
8
11
9
return useQuery({
12
10
queryKey: ['self'],
13
11
queryFn: async () => {
14
-
const res = await rpc.get('com.atproto.repo.getRecord', {
12
+
const res = await rpc.get('blue.recipes.actor.getProfile', {
15
13
params: {
16
-
repo: agent?.sub as At.DID,
17
-
collection: 'app.bsky.actor.profile',
18
-
rkey: 'self',
14
+
actor: agent?.sub!
19
15
},
20
16
});
21
17
22
-
return res.data.value as AppBskyActorProfile.Record;
18
+
return res.data as BlueRecipesActorDefs.ProfileViewDetailed;
23
19
},
24
20
enabled: isLoggedIn,
25
21
});
+3
-3
apps/web/src/routes/_.(app)/index.lazy.tsx
+3
-3
apps/web/src/routes/_.(app)/index.lazy.tsx
···
30
30
<BreadcrumbList>
31
31
<BreadcrumbItem className="hidden md:block">
32
32
<BreadcrumbLink asChild>
33
-
<Link href="/">Community</Link>
33
+
<Link to="/">Community</Link>
34
34
</BreadcrumbLink>
35
35
</BreadcrumbItem>
36
36
<BreadcrumbSeparator className="hidden md:block" />
···
48
48
<div className="flex-1 flex flex-col items-center p-4">
49
49
<div className="flex flex-col gap-4 max-w-2xl w-full items-center">
50
50
<QueryPlaceholder query={query} cards cardsCount={12}>
51
-
{query.data?.recipes.map((recipe, idx) => (
51
+
{data => data.recipes.map(recipe => (
52
52
<RecipeCard
53
53
recipe={recipe}
54
-
key={idx}
54
+
key={`${recipe.author.did}-${recipe.rkey}`}
55
55
/>
56
56
))}
57
57
</QueryPlaceholder>
+1
-32
apps/web/src/routes/_.(app)/recipes/new.tsx
+1
-32
apps/web/src/routes/_.(app)/recipes/new.tsx
···
9
9
} from "@/components/ui/breadcrumb";
10
10
import { Separator } from "@/components/ui/separator";
11
11
import { SidebarTrigger } from "@/components/ui/sidebar";
12
-
import { useFieldArray, useForm } from "react-hook-form";
13
-
import { z } from "zod";
14
-
import { zodResolver } from "@hookform/resolvers/zod";
15
-
import {
16
-
Form,
17
-
FormControl,
18
-
FormDescription,
19
-
FormField,
20
-
FormItem,
21
-
FormLabel,
22
-
FormMessage,
23
-
} from "@/components/ui/form";
24
-
import { Button } from "@/components/ui/button";
25
-
import { Input } from "@/components/ui/input";
26
-
import { Textarea } from "@/components/ui/textarea";
27
-
import {
28
-
Card,
29
-
CardContent,
30
-
CardDescription,
31
-
CardHeader,
32
-
CardTitle,
33
-
} from "@/components/ui/card";
34
-
import {
35
-
Sortable,
36
-
SortableDragHandle,
37
-
SortableItem,
38
-
} from "@/components/ui/sortable";
39
-
import { DragHandleDots2Icon } from "@radix-ui/react-icons";
40
-
import { Label } from "@/components/ui/label";
41
-
import { TrashIcon } from "lucide-react";
42
-
import { useNewRecipeMutation } from "@/queries/recipe";
43
12
44
13
export const Route = createFileRoute("/_/(app)/recipes/new")({
45
14
beforeLoad: async ({ context }) => {
46
-
if (!context.auth.isLoggedIn) {
15
+
if (!context.session.isLoggedIn) {
47
16
throw redirect({
48
17
to: '/login',
49
18
});
+8
-8
apps/web/src/screens/Recipes/RecipeCard.tsx
+8
-8
apps/web/src/screens/Recipes/RecipeCard.tsx
···
1
1
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
2
2
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
3
-
import { BlueRecipesFeedGetRecipes } from "@atcute/client/lexicons";
4
3
import { Link } from "@tanstack/react-router";
5
4
import { Clock, CookingPot, ListIcon } from "lucide-react";
5
+
import { BlueRecipesFeedGetRecipes } from "@cookware/lexicons";
6
6
7
7
type RecipeCardProps = {
8
-
recipe: BlueRecipesFeedGetRecipes.Result;
8
+
recipe: BlueRecipesFeedGetRecipes.$output['recipes'][0];
9
9
};
10
10
11
11
export const RecipeCard = ({ recipe }: RecipeCardProps) => {
···
13
13
<Link to="/recipes/$author/$rkey" params={{ author: recipe.author.handle, rkey: recipe.rkey }} className="w-full">
14
14
<Card className="w-full">
15
15
<CardHeader>
16
+
<CardTitle>{recipe.record.title}</CardTitle>
16
17
<CardDescription className="flex items-center space-x-2">
17
18
<Avatar className="h-6 w-6 rounded-lg">
18
-
<AvatarImage src={recipe.author.avatarUrl} alt={recipe.author.displayName} />
19
+
<AvatarImage src={recipe.author.avatar} alt={recipe.author.displayName} />
19
20
<AvatarFallback className="rounded-lg">{recipe.author.displayName}</AvatarFallback>
20
21
</Avatar>
21
22
22
23
<span>{recipe.author.displayName}</span>
23
24
</CardDescription>
24
-
<CardTitle>{recipe.title}</CardTitle>
25
25
</CardHeader>
26
26
<CardContent>
27
-
<p>{recipe.description}</p>
27
+
<p>{recipe.record.description}</p>
28
28
</CardContent>
29
29
<CardFooter className="flex gap-6 text-sm text-muted-foreground">
30
30
<span className="flex items-center gap-2">
31
-
<ListIcon className="size-4" /> <span>{recipe.steps}</span>
31
+
<ListIcon className="size-4" /> <span>{recipe.record.steps.length}</span>
32
32
</span>
33
33
34
34
<span className="flex items-center gap-2">
35
-
<CookingPot className="size-4" /> <span>{recipe.ingredients}</span>
35
+
<CookingPot className="size-4" /> <span>{recipe.record.ingredients.length}</span>
36
36
</span>
37
37
38
38
<span className="flex items-center gap-2">
39
-
<Clock className="size-4" /> <span>{recipe.time} mins</span>
39
+
<Clock className="size-4" /> <span>{recipe.record.time} mins</span>
40
40
</span>
41
41
</CardFooter>
42
42
</Card>
+2
-2
apps/web/tailwind.config.js
+2
-2
apps/web/tailwind.config.js
···
1
1
import animate from 'tailwindcss-animate';
2
2
/** @type {import('tailwindcss').Config} */
3
3
export default {
4
-
darkMode: ["class"],
5
-
content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"],
4
+
darkMode: ["class"],
5
+
content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"],
6
6
theme: {
7
7
extend: {
8
8
borderRadius: {
+2
libs/lexicons/lexicons/feed/defs.tsp
+2
libs/lexicons/lexicons/feed/defs.tsp
+2
libs/lexicons/lib/types/blue/recipes/feed/defs.ts
+2
libs/lexicons/lib/types/blue/recipes/feed/defs.ts
···
18
18
return BlueRecipesActorDefs.profileViewBasicSchema;
19
19
},
20
20
cid: /*#__PURE__*/ v.cidString(),
21
+
imageUrl: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
21
22
indexedAt: /*#__PURE__*/ v.datetimeString(),
22
23
get record() {
23
24
return BlueRecipesFeedRecipe.mainSchema;
24
25
},
26
+
rkey: /*#__PURE__*/ v.string(),
25
27
uri: /*#__PURE__*/ v.resourceUriString(),
26
28
});
27
29