diff --git a/frontend/package.json b/frontend/package.json index c6715f2a..896d0f2f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "@hookform/resolvers": "^3.3.4", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-aspect-ratio": "^1.0.3", + "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", diff --git a/frontend/src/assets/Icon/General/Plus.svg b/frontend/src/assets/Icon/General/Plus.svg index 5b8f2627..c0bc9fb9 100644 --- a/frontend/src/assets/Icon/General/Plus.svg +++ b/frontend/src/assets/Icon/General/Plus.svg @@ -1,3 +1,3 @@ - + diff --git a/frontend/src/components/Comment.tsx b/frontend/src/components/Comment.tsx index 08f59b03..210077b9 100644 --- a/frontend/src/components/Comment.tsx +++ b/frontend/src/components/Comment.tsx @@ -1,4 +1,3 @@ -import React from "react"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { UserSummary } from "@/services/api/semanticBrowseSchemas"; diff --git a/frontend/src/components/NavbarLayout.tsx b/frontend/src/components/NavbarLayout.tsx index 25310da3..ac723cb9 100644 --- a/frontend/src/components/NavbarLayout.tsx +++ b/frontend/src/components/NavbarLayout.tsx @@ -1,4 +1,11 @@ -import { CircleUser, Menu, Package2, UtensilsCrossed } from "lucide-react"; +import { + CircleUser, + LogOut, + Menu, + Package2, + User, + UtensilsCrossed, +} from "lucide-react"; import { Link, NavLink, @@ -10,6 +17,8 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, DropdownMenuTrigger, } from "./ui/dropdown-menu"; import { Button } from "./ui/button"; @@ -107,11 +116,27 @@ export const NavbarLayout = () => { - - - - + Account + + + + + Profile + + + + + + + ) : ( diff --git a/frontend/src/components/Recipe.tsx b/frontend/src/components/Recipe.tsx index c861da98..51cb2549 100644 --- a/frontend/src/components/Recipe.tsx +++ b/frontend/src/components/Recipe.tsx @@ -6,29 +6,20 @@ import BookmarkIcon from "@/assets/Icon/General/Bookmark.svg"; import TimeIcon from "@/assets/Icon/General/Clock.svg"; import StarIcon from "@/assets/Icon/General/Star.svg"; import FoodIcon from "@/assets/Icon/General/Food.svg"; -import { DishSummary, UserSummary } from "@/services/api/semanticBrowseSchemas"; - -interface Recipe { - name: string; - images: string[]; - avgRating?: number; - ratingsCount: number; - cookTime: number; - dish: DishSummary; - author: UserSummary; -} +import { RecipeSummary } from "@/services/api/semanticBrowseSchemas"; +import { Link } from "react-router-dom"; export const Recipe = ({ - recipe: { name, images, avgRating, ratingsCount, cookTime, dish, author }, + recipe: { id, name, images, avgRating, ratingsCount, cookTime, dish, author }, }: { - recipe: Recipe; + recipe: RecipeSummary; }) => { return (
{name} @@ -58,20 +49,20 @@ export const Recipe = ({
-
- avgRating icon -

- {avgRating} ({ratingsCount} Reviews) -

+
+ avgRating icon + + {avgRating} ({ratingsCount || 0} Reviews) +
-
- Time icon -

{cookTime}

+
+ Time icon + {cookTime}
{dish && ( -
- Food icon -

{dish.name}

+
+ Food icon + {dish.name}
)} {author && author.profilePicture && ( @@ -84,9 +75,12 @@ export const Recipe = ({
)}
- + Go to recipe → - +
diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx index 9ba71988..16c3558e 100644 --- a/frontend/src/components/SearchBar.tsx +++ b/frontend/src/components/SearchBar.tsx @@ -19,8 +19,12 @@ export const SearchBar = () => {
{ e.preventDefault(); + const params = new URLSearchParams(); + params.append("q", search); + if (cuisine) params.append("cuisine", cuisine); + if (foodType) params.append("foodType", foodType); - navigate("/search?q=" + encodeURIComponent(search)); + navigate("/search?" + params.toString()); }} className="flex gap-4" > diff --git a/frontend/src/components/SearchFilterPopover.tsx b/frontend/src/components/SearchFilterPopover.tsx index bcef43eb..6b4ee13c 100644 --- a/frontend/src/components/SearchFilterPopover.tsx +++ b/frontend/src/components/SearchFilterPopover.tsx @@ -16,17 +16,24 @@ const predefinedCuisines = [ ]; const predefinedTypeOfFood = ["Meat", "Baked", "Dairy", "Eggs"]; -export default function SearchFilterPopover({ - cuisine, - setCuisine, - foodType, - setFoodType, -}: { +type CuisineFilter = { cuisine: string; setCuisine: (cuisine: string) => void; +}; +type FoodTypeFilter = { foodType: string; setFoodType: (foodType: string) => void; -}) { +}; +export default function SearchFilterPopover( + props: (object | CuisineFilter) & (object | FoodTypeFilter), +) { + const { + cuisine = "", + foodType = "", + setCuisine = null, + setFoodType = null, + } = props as CuisineFilter & FoodTypeFilter; + const [tempCuisine, setTempCuisine] = useState(cuisine); const [tempFoodType, setTempFoodType] = useState(foodType); @@ -63,40 +70,50 @@ export default function SearchFilterPopover({ -

Filter

-
-
Cuisine
-
- {predefinedCuisines.map((cuisine) => ( - - setTempCuisine(e.target.checked ? cuisine : "") - } - /> - ))} -
-
-
-
Type of Food
-
- {predefinedTypeOfFood.map((type) => ( - setTempFoodType(e.target.checked ? type : "")} - /> - ))} -
-
+ {setCuisine && ( + <> +

Filter

+
+
Cuisine
+
+ {predefinedCuisines.map((cuisine) => ( + + setTempCuisine(e.target.checked ? cuisine : "") + } + /> + ))} +
+
+ + )} + {setFoodType && ( + <> +
+
Type of Food
+
+ {predefinedTypeOfFood.map((type) => ( + + setTempFoodType(e.target.checked ? type : "") + } + /> + ))} +
+
+ + )} - -
-
navigate("/filter")}> - -
-
-
- {feedData?.data?.map((recipe) => ( - - ))} +
+

+ {feedData?.data?.length + ? `Found ${feedData.data.length} results` + : "No recipes found"} +

+
+ setParams((prev) => ({ ...prev, type: val }))} + > + + Following + Explore + + +
+ {feedData?.data?.map((recipe) => ( + + ))} +
+
+ +
+ {feedData?.data?.map((recipe) => ( + + ))} +
+
+
); }; diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index 4f98cd2d..c0086c49 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -6,6 +6,7 @@ import { signout } from "../services/auth"; import { Search } from "./search"; import { Feed } from "./feed"; import { NavbarLayout } from "../components/NavbarLayout"; +import Profile from "./profile"; import RecipePage from "./recipe"; export const routes: RouteObject[] = [ @@ -33,6 +34,10 @@ export const routes: RouteObject[] = [ index: true, Component: IndexRoute, }, + { + path: "/users/:userId", + Component: Profile, + }, { path: "/logout", async action() { diff --git a/frontend/src/routes/profile.tsx b/frontend/src/routes/profile.tsx new file mode 100644 index 00000000..f741d32a --- /dev/null +++ b/frontend/src/routes/profile.tsx @@ -0,0 +1,93 @@ +import { useGetUserById } from "@/services/api/semanticBrowseComponents"; +import { useParams } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { AvatarImage, Avatar } from "@/components/ui/avatar"; +import Plus from "@/assets/Icon/General/Plus.svg?react"; +import { FullscreenLoading } from "@/components/FullscreenLoading"; +import ErrorAlert from "@/components/ErrorAlert"; +import { cn } from "@/lib/utils"; +import { Recipe } from "@/components/Recipe"; + +export default function Profile() { + const { userId = "" } = useParams<{ userId: string }>(); + const me = userId === "me"; + + const { isLoading, data, error } = useGetUserById({ + pathParams: { userId: me ? ("me" as unknown as number) : parseInt(userId) }, + queryParams: { + enabled: !me && !isNaN(Number(userId)), + }, + }); + + if (!me && isNaN(Number(userId))) { + return

Invalid user id

; + } + if (isLoading) { + return ; + } + if (error) { + return ; + } + + const profile = data!.data; + return ( +
+
+
+

My profile

+
+
+ + + +
+
+
{profile.recipeCount}
+
Recipes
+
+
+
{profile.followersCount}
+
Followers
+
+
+
{profile.followingCount}
+
Following
+
+
+
+
+
+

{profile.name}

+

+ {profile.bio ?? "Empty bio."} +

+
+ {me && } +
+
+
+
+

Recipes

+ {me && ( + + )} +
+
+ {profile.recipes?.map((recipe) => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/src/routes/recipe.tsx b/frontend/src/routes/recipe.tsx index 4a3a8449..7e76014a 100644 --- a/frontend/src/routes/recipe.tsx +++ b/frontend/src/routes/recipe.tsx @@ -43,7 +43,6 @@ export default function RecipePage() { }, }); - console.log(data, isLoading, error); if (isLoading) { return ; } diff --git a/frontend/src/routes/search.tsx b/frontend/src/routes/search.tsx index 36c531ec..c03e73ce 100644 --- a/frontend/src/routes/search.tsx +++ b/frontend/src/routes/search.tsx @@ -14,7 +14,11 @@ export const Search = () => { isLoading, error, } = useSearchDishes({ - queryParams: { q: params.get("q") ?? "" }, + queryParams: { + q: params.get("q") ?? "", + ...(params.get("cuisine") ? { cuisine: params.get("cuisine")! } : {}), + ...(params.get("foodType") ? { foodType: params.get("foodType")! } : {}), + }, }); if (isLoading) { diff --git a/frontend/src/services/api/semanticBrowseSchemas.ts b/frontend/src/services/api/semanticBrowseSchemas.ts index 25128184..23113e02 100644 --- a/frontend/src/services/api/semanticBrowseSchemas.ts +++ b/frontend/src/services/api/semanticBrowseSchemas.ts @@ -23,7 +23,7 @@ export type AuthToken = { }; /** - * @example {"id":1,"username":"takoyaki_lover","name":"Takoyaki Lover","bio":"I love takoyaki!","followersCount":100,"gender":"unknown","profilePicture":"https://images.unsplash.com/photo-1633790450512-98e68a55ef15?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&dl=brunno-tozzo-GAIC2WHxm5A-unsplash.jpg&w=640","diets":["keto"],"selfFollowing":true,"recipeCount":10,"bookmarks":[{"id":1,"name":"My Takoyaki Recipe","description":"A delicious takoyaki recipe that I learned from my grandmother.","cookTime":30,"images":["http://commons.wikimedia.org/wiki/Special:FilePath/Takoyaki%20by%20yomi955.jpg"],"rating":4.5,"dish":{"id":"http://www.wikidata.org/entity/Q905527","name":"takoyaki"}}],"recipes":[{"id":1,"name":"My Takoyaki Recipe","description":"A delicious takoyaki recipe that I learned from my grandmother.","cookTime":30,"images":["http://commons.wikimedia.org/wiki/Special:FilePath/Takoyaki%20by%20yomi955.jpg"],"rating":4.5,"dish":{"id":"http://www.wikidata.org/entity/Q905527","name":"takoyaki"}}]} + * @example {"id":1,"username":"takoyaki_lover","name":"Takoyaki Lover","bio":"I love takoyaki!","followersCount":100,"followingCount":100,"gender":"unknown","profilePicture":"https://images.unsplash.com/photo-1633790450512-98e68a55ef15?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&dl=brunno-tozzo-GAIC2WHxm5A-unsplash.jpg&w=640","diets":["keto"],"selfFollowing":true,"recipeCount":10,"bookmarks":[{"id":1,"name":"My Takoyaki Recipe","description":"A delicious takoyaki recipe that I learned from my grandmother.","cookTime":30,"images":["http://commons.wikimedia.org/wiki/Special:FilePath/Takoyaki%20by%20yomi955.jpg"],"rating":4.5,"dish":{"id":"http://www.wikidata.org/entity/Q905527","name":"takoyaki"}}],"recipes":[{"id":1,"name":"My Takoyaki Recipe","description":"A delicious takoyaki recipe that I learned from my grandmother.","cookTime":30,"images":["http://commons.wikimedia.org/wiki/Special:FilePath/Takoyaki%20by%20yomi955.jpg"],"rating":4.5,"dish":{"id":"http://www.wikidata.org/entity/Q905527","name":"takoyaki"}}]} */ export type UserProfile = { id?: number; @@ -31,6 +31,7 @@ export type UserProfile = { name?: string; bio?: string; followersCount?: number; + followingCount?: number; gender?: "male" | "female" | "unknown"; /** * @format uri diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 2c0d9f1b..acdf80ca 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -896,6 +896,29 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-avatar@npm:^1.0.4": + version: 1.0.4 + resolution: "@radix-ui/react-avatar@npm:1.0.4" + dependencies: + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/react-context": "npm:1.0.1" + "@radix-ui/react-primitive": "npm:1.0.3" + "@radix-ui/react-use-callback-ref": "npm:1.0.1" + "@radix-ui/react-use-layout-effect": "npm:1.0.1" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/608494c53968085bfcf9b987d80c3ec6720bdb65f78591d53e8bba3b360e86366d48a7dee11405dd443f5a3565432184b95bb9d4954bca1922cc9385a942caaf + languageName: node + linkType: hard + "@radix-ui/react-collapsible@npm:1.0.3": version: 1.0.3 resolution: "@radix-ui/react-collapsible@npm:1.0.3" @@ -4298,6 +4321,7 @@ __metadata: "@openapi-codegen/typescript": "npm:^8.0.2" "@radix-ui/react-accordion": "npm:^1.1.2" "@radix-ui/react-aspect-ratio": "npm:^1.0.3" + "@radix-ui/react-avatar": "npm:^1.0.4" "@radix-ui/react-dialog": "npm:^1.0.5" "@radix-ui/react-dropdown-menu": "npm:^2.0.6" "@radix-ui/react-label": "npm:^2.0.2" diff --git a/swagger/openapi.yml b/swagger/openapi.yml index 320b899b..7a662e5c 100644 --- a/swagger/openapi.yml +++ b/swagger/openapi.yml @@ -997,6 +997,8 @@ components: type: string followersCount: type: integer + followingCount: + type: integer gender: type: string enum: [male, female, unknown] @@ -1026,6 +1028,7 @@ components: name: "Takoyaki Lover" bio: "I love takoyaki!" followersCount: 100 + followingCount: 100 gender: unknown profilePicture: "https://images.unsplash.com/photo-1633790450512-98e68a55ef15?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&dl=brunno-tozzo-GAIC2WHxm5A-unsplash.jpg&w=640" diets: ["keto"]