From 5212429fb17e706fef51282c02a2821ea03912dc Mon Sep 17 00:00:00 2001 From: Tantikon Phasanphaengsi Date: Sat, 10 May 2025 20:43:21 +0700 Subject: [PATCH] feat: show element in tabs profile --- app/(tabs)/profile.tsx | 361 ++++++++++++++++++++++--------------- services/data/bookmarks.ts | 48 +++++ services/data/likes.ts | 48 +++++ 3 files changed, 307 insertions(+), 150 deletions(-) create mode 100644 services/data/bookmarks.ts create mode 100644 services/data/likes.ts diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx index bc05f4d..f5f8806 100644 --- a/app/(tabs)/profile.tsx +++ b/app/(tabs)/profile.tsx @@ -1,13 +1,15 @@ -"use client"; +"use client" -import { useAuth } from "@/context/auth-context"; -import { getFoods } from "@/services/data/foods"; -import { getProfile, updateProfile } from "@/services/data/profile"; -import { supabase } from "@/services/supabase"; -import { useIsFocused } from "@react-navigation/native"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import * as ImagePicker from "expo-image-picker"; -import { useState } from "react"; +import { useAuth } from "@/context/auth-context" +import { getFoods } from "@/services/data/foods" +import { getBookmarkedPosts } from "@/services/data/bookmarks" +import { getLikedPosts } from "@/services/data/likes" +import { getProfile, updateProfile } from "@/services/data/profile" +import { supabase } from "@/services/supabase" +import { useIsFocused, useNavigation } from "@react-navigation/native" +import { useQuery, useQueryClient } from "@tanstack/react-query" +import * as ImagePicker from "expo-image-picker" +import { useEffect, useState } from "react" import { ActivityIndicator, Image, @@ -18,15 +20,31 @@ import { TextInput, TouchableOpacity, View, -} from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; -import uuid from "react-native-uuid"; +} from "react-native" +import { SafeAreaView } from "react-native-safe-area-context" +import uuid from "react-native-uuid" + +// Define the Food type based on your database structure +type Food = { + id: number + name: string + description: string + time_to_cook_minutes: number + skill_level: string + ingredient_count: number + calories: number + image_url: string + is_shared: boolean + created_by: string + created_at: string +} export default function ProfileScreen() { - const [activeTab, setActiveTab] = useState("My Recipes"); - const { isAuthenticated } = useAuth(); - const isFocused = useIsFocused(); - const queryClient = useQueryClient(); + const [activeTab, setActiveTab] = useState("My Recipes") + const { isAuthenticated } = useAuth() + const isFocused = useIsFocused() + const queryClient = useQueryClient() + const navigation = useNavigation() const { data: userData, @@ -35,14 +53,14 @@ export default function ProfileScreen() { } = useQuery({ queryKey: ["auth-user"], queryFn: async () => { - const { data, error } = await supabase.auth.getUser(); - if (error) throw error; - return data?.user; + const { data, error } = await supabase.auth.getUser() + if (error) throw error + return data?.user }, enabled: isAuthenticated, staleTime: 0, - }); - const userId = userData?.id; + }) + const userId = userData?.id const { data: profileData, @@ -51,115 +69,186 @@ export default function ProfileScreen() { } = useQuery({ queryKey: ["profile", userId], queryFn: async () => { - if (!userId) throw new Error("No user id"); - return getProfile(userId); + if (!userId) throw new Error("No user id") + return getProfile(userId) }, enabled: !!userId, staleTime: 0, subscribed: isFocused, - }); + }) + // My Recipes Query const { - data: foodsData, - isLoading: isFoodsLoading, - error: foodsError, + data: myRecipesData, + isLoading: isMyRecipesLoading, + error: myRecipesError, + refetch: refetchMyRecipes, } = useQuery({ queryKey: ["my-recipes", userId], queryFn: async () => { - if (!userId) throw new Error("No user id"); - return getFoods(userId); + if (!userId) throw new Error("No user id") + return getFoods(userId) }, - enabled: !!userId && activeTab === "My Recipes", - staleTime: 0, - }); + enabled: !!userId, + staleTime: 1000 * 60, // 1 minute + }) - const [modalVisible, setModalVisible] = useState(false); - const [editUsername, setEditUsername] = useState(""); - const [editImage, setEditImage] = useState(null); - const [editLoading, setEditLoading] = useState(false); - const [editError, setEditError] = useState(null); + // Likes Query + const { + data: likesData, + isLoading: isLikesLoading, + error: likesError, + refetch: refetchLikes, + } = useQuery({ + queryKey: ["liked-posts", userId], + queryFn: async () => { + if (!userId) throw new Error("No user id") + return getLikedPosts(userId) + }, + enabled: !!userId, + staleTime: 1000 * 60, // 1 minute + }) + + // Bookmarks Query + const { + data: bookmarksData, + isLoading: isBookmarksLoading, + error: bookmarksError, + refetch: refetchBookmarks, + } = useQuery({ + queryKey: ["bookmarked-posts", userId], + queryFn: async () => { + if (!userId) throw new Error("No user id") + return getBookmarkedPosts(userId) + }, + enabled: !!userId, + staleTime: 1000 * 60, // 1 minute + }) + + // Navigate to post detail + const handleFoodPress = (foodId: number) => { + // @ts-ignore - Navigation typing might be different in your app + navigation.navigate("post-detail", { id: foodId }) + } + + // Refetch data when tab changes + const handleTabChange = (tab: string) => { + setActiveTab(tab) + + // Refetch data for the selected tab + if (tab === "My Recipes") { + refetchMyRecipes() + } else if (tab === "Likes") { + refetchLikes() + } else if (tab === "Bookmark") { + refetchBookmarks() + } + } + + // Refetch all data when the screen comes into focus + useEffect(() => { + if (isFocused && userId) { + refetchMyRecipes() + refetchLikes() + refetchBookmarks() + } + }, [isFocused, userId]) + + const [modalVisible, setModalVisible] = useState(false) + const [editUsername, setEditUsername] = useState("") + const [editImage, setEditImage] = useState(null) + const [editLoading, setEditLoading] = useState(false) + const [editError, setEditError] = useState(null) const pickImage = async () => { - const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); + const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync() if (status !== "granted") { - setEditError("Permission to access media library is required."); - return; + setEditError("Permission to access media library is required.") + return } const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], quality: 0.7, allowsEditing: true, - }); + }) if (!result.canceled) { - setEditImage(result.assets[0].uri); + setEditImage(result.assets[0].uri) } - }; + } const uploadImageToSupabase = async (uri: string): Promise => { - const fileName = `${userId}/${uuid.v4()}.jpg`; - const response = await fetch(uri); - const blob = await response.blob(); + const fileName = `${userId}/${uuid.v4()}.jpg` + const response = await fetch(uri) + const blob = await response.blob() - const { error: uploadError } = await supabase.storage - .from("avatars") - .upload(fileName, blob, { - contentType: "image/jpeg", - upsert: true, - }); + const { error: uploadError } = await supabase.storage.from("avatars").upload(fileName, blob, { + contentType: "image/jpeg", + upsert: true, + }) - if (uploadError) throw uploadError; + if (uploadError) throw uploadError - const { data } = supabase.storage.from("avatars").getPublicUrl(fileName); - return data.publicUrl; - }; + const { data } = supabase.storage.from("avatars").getPublicUrl(fileName) + return data.publicUrl + } const handleSaveProfile = async () => { - setEditLoading(true); - setEditError(null); + setEditLoading(true) + setEditError(null) try { - if (!editUsername.trim()) throw new Error("Username cannot be empty"); + if (!editUsername.trim()) throw new Error("Username cannot be empty") - let avatarUrl = profileData?.data?.avatar_url ?? null; + let avatarUrl = profileData?.data?.avatar_url ?? null if (editImage && editImage !== avatarUrl) { - avatarUrl = await uploadImageToSupabase(editImage); + avatarUrl = await uploadImageToSupabase(editImage) } - const { error: updateError } = await updateProfile( - userId!, - editUsername.trim(), - avatarUrl - ); - if (updateError) throw updateError; + const { error: updateError } = await updateProfile(userId!, editUsername.trim(), avatarUrl) + if (updateError) throw updateError - setModalVisible(false); - await queryClient.invalidateQueries({ queryKey: ["profile", userId] }); + setModalVisible(false) + await queryClient.invalidateQueries({ queryKey: ["profile", userId] }) } catch (err: any) { - setEditError(err.message || "Failed to update profile"); + setEditError(err.message || "Failed to update profile") } finally { - setEditLoading(false); + setEditLoading(false) } - }; + } + + // Get the active data based on the current tab + const getActiveData = () => { + switch (activeTab) { + case "My Recipes": + return { data: myRecipesData, isLoading: isMyRecipesLoading, error: myRecipesError } + case "Likes": + return { data: likesData, isLoading: isLikesLoading, error: likesError } + case "Bookmark": + return { data: bookmarksData, isLoading: isBookmarksLoading, error: bookmarksError } + default: + return { data: myRecipesData, isLoading: isMyRecipesLoading, error: myRecipesError } + } + } + + const { data: activeData, isLoading: isActiveLoading, error: activeError } = getActiveData() if (isUserLoading) { return ( - ); + ) } if (userError) { return ( - - {userError.message || "Failed to load user data."} - + {userError.message || "Failed to load user data."} - ); + ) } return ( @@ -179,46 +268,31 @@ export default function ProfileScreen() { {isLoading ? ( ) : error ? ( - - {error.message || error.toString()} - + {error.message || error.toString()} ) : ( - - {profileData?.data?.username ?? "-"} - + {profileData?.data?.username ?? "-"} )} { - setEditUsername(profileData?.data?.username ?? ""); - setEditImage(profileData?.data?.avatar_url ?? null); - setEditError(null); - setModalVisible(true); + setEditUsername(profileData?.data?.username ?? "") + setEditImage(profileData?.data?.avatar_url ?? null) + setEditError(null) + setModalVisible(true) }} > Edit {/* Edit Modal */} - setModalVisible(false)} - > + setModalVisible(false)}> - - Edit Profile - + Edit Profile Change Photo @@ -231,11 +305,7 @@ export default function ProfileScreen() { onChangeText={setEditUsername} placeholder="Enter new username" /> - {editError && ( - - {editError} - - )} + {editError && {editError}} {/* Tab Navigation */} - - {["My Recipes", "Likes", "Saved"].map((tab) => ( + + {["My Recipes", "Likes", "Bookmark"].map((tab) => ( setActiveTab(tab)} + className={`py-2 px-4 ${activeTab === tab ? "border-b-2 border-[#333]" : ""}`} + onPress={() => handleTabChange(tab)} > - {tab} + {tab} ))} - - - {/* Recipes */} - {activeTab === "My Recipes" && ( + {/* Tab Content */} + {isActiveLoading ? ( + + + + ) : activeError ? ( + + {activeError.message || "Failed to load data"} + + ) : !activeData?.data?.length ? ( + + No items found + + ) : ( - {isFoodsLoading ? ( - - ) : foodsError ? ( - - {foodsError.message || foodsError.toString()} - - ) : foodsData?.data?.length ? ( - foodsData.data.map((item) => ( - - - - - {item.name} - - + {activeData.data.map((item: Food) => ( + handleFoodPress(item.id)} + activeOpacity={0.7} + > + + + {item.name} - )) - ) : ( - - No recipes found. - - )} + + ))} )} - ); + ) } diff --git a/services/data/bookmarks.ts b/services/data/bookmarks.ts new file mode 100644 index 0000000..c6b7d47 --- /dev/null +++ b/services/data/bookmarks.ts @@ -0,0 +1,48 @@ +import { supabase } from "@/services/supabase" +import type { PostgrestError } from "@supabase/supabase-js" + +/** + * Retrieves posts that a user has saved/bookmarked + */ +export async function getBookmarkedPosts(userId: string): Promise<{ + data: any[] | null + error: PostgrestError | null +}> { + // First get all food_ids that the user has saved + const { data: savedFoodIds, error: saveError } = await supabase + .from("food_saves") + .select("food_id") + .eq("user_id", userId) + + if (saveError) { + return { data: null, error: saveError } + } + + if (!savedFoodIds || savedFoodIds.length === 0) { + return { data: [], error: null } + } + + // Extract just the IDs + const foodIds = savedFoodIds.map((item) => item.food_id) + + // Then fetch the actual food items + const { data, error } = await supabase + .from("foods") + .select(` + id, + name, + description, + time_to_cook_minutes, + skill_level, + ingredient_count, + calories, + image_url, + is_shared, + created_by, + created_at + `) + .in("id", foodIds) + .order("created_at", { ascending: false }) + + return { data, error } +} diff --git a/services/data/likes.ts b/services/data/likes.ts new file mode 100644 index 0000000..62d187c --- /dev/null +++ b/services/data/likes.ts @@ -0,0 +1,48 @@ +import { supabase } from "@/services/supabase" +import type { PostgrestError } from "@supabase/supabase-js" + +/** + * Retrieves posts that a user has liked + */ +export async function getLikedPosts(userId: string): Promise<{ + data: any[] | null + error: PostgrestError | null +}> { + // First get all food_ids that the user has liked + const { data: likedFoodIds, error: likeError } = await supabase + .from("food_likes") + .select("food_id") + .eq("user_id", userId) + + if (likeError) { + return { data: null, error: likeError } + } + + if (!likedFoodIds || likedFoodIds.length === 0) { + return { data: [], error: null } + } + + // Extract just the IDs + const foodIds = likedFoodIds.map((item) => item.food_id) + + // Then fetch the actual food items + const { data, error } = await supabase + .from("foods") + .select(` + id, + name, + description, + time_to_cook_minutes, + skill_level, + ingredient_count, + calories, + image_url, + is_shared, + created_by, + created_at + `) + .in("id", foodIds) + .order("created_at", { ascending: false }) + + return { data, error } +}