This commit is contained in:
Sosokker 2025-05-11 02:46:40 +07:00
commit 6933392271
13 changed files with 904 additions and 828 deletions

View File

@ -28,7 +28,7 @@
[ [
"expo-splash-screen", "expo-splash-screen",
{ {
"image": "./assets/images/splash-icon.png", "image": "./assets/images/splash.png",
"imageWidth": 200, "imageWidth": 200,
"resizeMode": "contain", "resizeMode": "contain",
"backgroundColor": "#ffffff" "backgroundColor": "#ffffff"

View File

@ -1,23 +1,17 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { View, Text, Image, TextInput, TouchableOpacity, FlatList, SafeAreaView, ActivityIndicator, Alert } from 'react-native'; import { View, Text, Image, TextInput, TouchableOpacity, FlatList, SafeAreaView, ActivityIndicator, Alert } from 'react-native';
import { Feather, FontAwesome, Ionicons } from '@expo/vector-icons'; import { Feather, FontAwesome } from '@expo/vector-icons';
import { router } from 'expo-router'; import { router, useFocusEffect } from 'expo-router';
import { useAuth } from '../../context/auth-context'; import { useAuth } from '../../context/auth-context';
import { getFoods } from '../../services/data/foods';
import {
getLikesCount,
getSavesCount,
getCommentsCount,
createLike,
deleteLike,
createSave,
deleteSave,
checkUserLiked,
checkUserSaved
} from '../../services/data/forum';
import { getProfile } from '../../services/data/profile';
import { Food, Profile } from '../../types/index';
import { supabase } from '../../services/supabase'; import { supabase } from '../../services/supabase';
import {
useFoods,
useFoodStats,
useFoodCreators,
useUserInteractions,
useLikeMutation,
useSaveMutation
} from '../../hooks/use-foods';
// Categories for filtering // Categories for filtering
const categories = [ const categories = [
@ -37,13 +31,8 @@ export default function ForumScreen() {
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const [currentUserId, setCurrentUserId] = useState<string | null>(null); const [currentUserId, setCurrentUserId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [foods, setFoods] = useState<Food[]>([]);
const [loading, setLoading] = useState(true);
const [selectedCategory, setSelectedCategory] = useState(''); const [selectedCategory, setSelectedCategory] = useState('');
const [selectedSort, setSelectedSort] = useState('rating'); const [selectedSort, setSelectedSort] = useState('rating');
const [foodStats, setFoodStats] = useState<{[key: string]: {likes: number, saves: number, comments: number}}>({});
const [foodCreators, setFoodCreators] = useState<{[key: string]: Profile}>({});
const [userInteractions, setUserInteractions] = useState<{[key: string]: {liked: boolean, saved: boolean}}>({});
// Get current user ID from Supabase session // Get current user ID from Supabase session
useEffect(() => { useEffect(() => {
@ -61,169 +50,53 @@ export default function ForumScreen() {
getCurrentUser(); getCurrentUser();
}, [isAuthenticated]); }, [isAuthenticated]);
// Set up real-time subscription for likes and saves // Use React Query hooks
useEffect(() => { const {
if (!isAuthenticated) return; data: foods = [],
isLoading: isLoadingFoods,
refetch: refetchFoods
} = useFoods(selectedCategory, searchQuery, selectedSort);
const likesSubscription = supabase const foodIds = foods.map(food => food.id);
.channel('food_likes_changes')
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'food_likes'
}, () => {
// Refresh stats when changes occur
loadFoods();
})
.subscribe();
const savesSubscription = supabase const {
.channel('food_saves_changes') data: foodStats = {},
.on('postgres_changes', { isLoading: isLoadingStats
event: '*', } = useFoodStats(foodIds);
schema: 'public',
table: 'food_saves'
}, () => {
// Refresh stats when changes occur
loadFoods();
})
.subscribe();
return () => { const creatorIds = foods
supabase.removeChannel(likesSubscription);
supabase.removeChannel(savesSubscription);
};
}, [isAuthenticated]);
useEffect(() => {
loadFoods();
}, [selectedCategory, selectedSort, currentUserId]);
const loadFoods = async () => {
setLoading(true);
try {
// In a real app, you would filter by category and sort accordingly
const { data, error } = await getFoods(undefined, true, searchQuery);
if (error) {
console.error('Error loading foods:', error);
return;
}
if (data) {
// Sort data based on selectedSort
let sortedData = [...data];
if (selectedSort === 'rating') {
// Assuming higher calories means higher rating for demo purposes
sortedData.sort((a, b) => (b.calories ?? 0) - (a.calories ?? 0));
} else if (selectedSort === 'newest') {
sortedData.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
} else if (selectedSort === 'best') {
// Assuming higher ingredient_count means better for demo purposes
sortedData.sort((a, b) => (b.ingredient_count ?? 0) - (a.ingredient_count ?? 0));
}
setFoods(sortedData.map(food => ({
...food,
description: food.description || '', // Ensure description is always a string
ingredient_count: food.ingredient_count ?? 0, // Ensure ingredient_count is always a number
calories: food.calories ?? 0, // Ensure calories is always a number
image_url: food.image_url || '', // Ensure image_url is always a string
})));
// Load stats for each food
const statsPromises = sortedData.map(async (food) => {
const [likesRes, savesRes, commentsRes] = await Promise.all([
getLikesCount(food.id),
getSavesCount(food.id),
getCommentsCount(food.id)
]);
return {
foodId: food.id,
likes: likesRes.count || 0,
saves: savesRes.count || 0,
comments: commentsRes.count || 0
};
});
const stats = await Promise.all(statsPromises);
const statsMap = stats.reduce((acc, stat) => {
acc[stat.foodId] = {
likes: stat.likes,
saves: stat.saves,
comments: stat.comments
};
return acc;
}, {} as {[key: string]: {likes: number, saves: number, comments: number}});
setFoodStats(statsMap);
// Load creator profiles
const creatorIds = sortedData
.filter(food => food.created_by) .filter(food => food.created_by)
.map(food => food.created_by as string); .map(food => food.created_by as string);
const uniqueCreatorIds = [...new Set(creatorIds)]; const {
data: foodCreators = {},
isLoading: isLoadingCreators
} = useFoodCreators(creatorIds);
const creatorProfiles: {[key: string]: Profile} = {}; const {
data: userInteractions = {},
isLoading: isLoadingInteractions
} = useUserInteractions(foodIds, currentUserId);
for (const creatorId of uniqueCreatorIds) { const likeMutation = useLikeMutation();
const { data: profile } = await getProfile(creatorId); const saveMutation = useSaveMutation();
if (profile) {
creatorProfiles[creatorId] = profile;
}
}
setFoodCreators(creatorProfiles); // Refetch data when the screen comes into focus
useFocusEffect(
// Check user interactions if authenticated React.useCallback(() => {
if (isAuthenticated && currentUserId) { refetchFoods();
const interactionsPromises = sortedData.map(async (food) => { }, [refetchFoods])
const [likedRes, savedRes] = await Promise.all([ );
checkUserLiked(food.id, currentUserId),
checkUserSaved(food.id, currentUserId)
]);
return {
foodId: food.id,
liked: !!likedRes.data,
saved: !!savedRes.data
};
});
const interactions = await Promise.all(interactionsPromises);
const interactionsMap = interactions.reduce((acc, interaction) => {
acc[interaction.foodId] = {
liked: interaction.liked,
saved: interaction.saved
};
return acc;
}, {} as {[key: string]: {liked: boolean, saved: boolean}});
setUserInteractions(interactionsMap);
}
}
} catch (error) {
console.error('Error:', error);
} finally {
setLoading(false);
}
};
const handleSearch = (text: string) => { const handleSearch = (text: string) => {
setSearchQuery(text); setSearchQuery(text);
// Debounce search for better performance
setTimeout(() => {
loadFoods();
}, 500);
}; };
const navigateToPostDetail = (food: Food) => { const navigateToPostDetail = (food: { id: string }) => {
router.push(`/post-detail/${food.id}`); router.push(`/post-detail/${food.id}`);
}; };
const handleLike = async (food: Food) => { const handleLike = async (food: { id: string }) => {
if (!isAuthenticated || !currentUserId) { if (!isAuthenticated || !currentUserId) {
Alert.alert('Authentication Required', 'Please log in to like posts.'); Alert.alert('Authentication Required', 'Please log in to like posts.');
return; return;
@ -232,77 +105,18 @@ const navigateToPostDetail = (food: Food) => {
try { try {
const isLiked = userInteractions[food.id]?.liked || false; const isLiked = userInteractions[food.id]?.liked || false;
// Optimistically update UI likeMutation.mutate({
setUserInteractions(prev => ({ foodId: food.id,
...prev, userId: currentUserId,
[food.id]: { isLiked
...prev[food.id], });
liked: !isLiked
}
}));
setFoodStats(prev => ({
...prev,
[food.id]: {
...prev[food.id],
likes: isLiked ? Math.max(0, prev[food.id].likes - 1) : prev[food.id].likes + 1
}
}));
if (isLiked) {
const { error } = await deleteLike(food.id, currentUserId);
if (error) {
console.error('Error deleting like:', error);
// Revert optimistic update if there's an error
setUserInteractions(prev => ({
...prev,
[food.id]: {
...prev[food.id],
liked: true
}
}));
setFoodStats(prev => ({
...prev,
[food.id]: {
...prev[food.id],
likes: prev[food.id].likes + 1
}
}));
Alert.alert('Error', 'Failed to unlike. Please try again.');
}
} else {
const { error } = await createLike(food.id, currentUserId);
if (error) {
console.error('Error creating like:', error);
// Revert optimistic update if there's an error
setUserInteractions(prev => ({
...prev,
[food.id]: {
...prev[food.id],
liked: false
}
}));
setFoodStats(prev => ({
...prev,
[food.id]: {
...prev[food.id],
likes: Math.max(0, prev[food.id].likes - 1)
}
}));
Alert.alert('Error', 'Failed to like. Please try again.');
}
}
} catch (error) { } catch (error) {
console.error('Error toggling like:', error); console.error('Error toggling like:', error);
Alert.alert('Error', 'Failed to update like. Please try again.'); Alert.alert('Error', 'Failed to update like. Please try again.');
} }
}; };
const handleSave = async (food: Food) => { const handleSave = async (food: { id: string }) => {
if (!isAuthenticated || !currentUserId) { if (!isAuthenticated || !currentUserId) {
Alert.alert('Authentication Required', 'Please log in to save posts.'); Alert.alert('Authentication Required', 'Please log in to save posts.');
return; return;
@ -311,77 +125,18 @@ const navigateToPostDetail = (food: Food) => {
try { try {
const isSaved = userInteractions[food.id]?.saved || false; const isSaved = userInteractions[food.id]?.saved || false;
// Optimistically update UI saveMutation.mutate({
setUserInteractions(prev => ({ foodId: food.id,
...prev, userId: currentUserId,
[food.id]: { isSaved
...prev[food.id], });
saved: !isSaved
}
}));
setFoodStats(prev => ({
...prev,
[food.id]: {
...prev[food.id],
saves: isSaved ? Math.max(0, prev[food.id].saves - 1) : prev[food.id].saves + 1
}
}));
if (isSaved) {
const { error } = await deleteSave(food.id, currentUserId);
if (error) {
console.error('Error deleting save:', error);
// Revert optimistic update if there's an error
setUserInteractions(prev => ({
...prev,
[food.id]: {
...prev[food.id],
saved: true
}
}));
setFoodStats(prev => ({
...prev,
[food.id]: {
...prev[food.id],
saves: prev[food.id].saves + 1
}
}));
Alert.alert('Error', 'Failed to unsave. Please try again.');
}
} else {
const { error } = await createSave(food.id, currentUserId);
if (error) {
console.error('Error creating save:', error);
// Revert optimistic update if there's an error
setUserInteractions(prev => ({
...prev,
[food.id]: {
...prev[food.id],
saved: false
}
}));
setFoodStats(prev => ({
...prev,
[food.id]: {
...prev[food.id],
saves: Math.max(0, prev[food.id].saves - 1)
}
}));
Alert.alert('Error', 'Failed to save. Please try again.');
}
}
} catch (error) { } catch (error) {
console.error('Error toggling save:', error); console.error('Error toggling save:', error);
Alert.alert('Error', 'Failed to update save. Please try again.'); Alert.alert('Error', 'Failed to update save. Please try again.');
} }
}; };
const renderFoodItem = ({ item }: { item: Food }) => { const renderFoodItem = ({ item }: { item: any }) => {
// Get stats for this food // Get stats for this food
const stats = foodStats[item.id] || { likes: 0, saves: 0, comments: 0 }; const stats = foodStats[item.id] || { likes: 0, saves: 0, comments: 0 };
@ -484,6 +239,8 @@ const navigateToPostDetail = (food: Food) => {
); );
}; };
const isLoading = isLoadingFoods || isLoadingStats || isLoadingCreators || isLoadingInteractions;
return ( return (
<SafeAreaView className="flex-1 bg-white"> <SafeAreaView className="flex-1 bg-white">
{/* Search Bar */} {/* Search Bar */}
@ -537,7 +294,7 @@ const navigateToPostDetail = (food: Food) => {
</View> </View>
{/* Food Posts */} {/* Food Posts */}
{loading ? ( {isLoading ? (
<View className="flex-1 items-center justify-center"> <View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#ffd60a" /> <ActivityIndicator size="large" color="#ffd60a" />
</View> </View>

View File

@ -1,13 +1,15 @@
"use client"; "use client"
import { useAuth } from "@/context/auth-context"; import { useAuth } from "@/context/auth-context"
import { getFoods } from "@/services/data/foods"; import { getFoods } from "@/services/data/foods"
import { getProfile, updateProfile } from "@/services/data/profile"; import { getBookmarkedPosts } from "@/services/data/bookmarks"
import { supabase } from "@/services/supabase"; import { getLikedPosts } from "@/services/data/likes"
import { useIsFocused } from "@react-navigation/native"; import { getProfile, updateProfile } from "@/services/data/profile"
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { supabase } from "@/services/supabase"
import * as ImagePicker from "expo-image-picker"; import { useIsFocused, useNavigation } from "@react-navigation/native"
import { useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"
import * as ImagePicker from "expo-image-picker"
import { useEffect, useState } from "react"
import { import {
ActivityIndicator, ActivityIndicator,
Image, Image,
@ -18,15 +20,31 @@ import {
TextInput, TextInput,
TouchableOpacity, TouchableOpacity,
View, View,
} from "react-native"; } from "react-native"
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context"
import uuid from "react-native-uuid"; 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() { export default function ProfileScreen() {
const [activeTab, setActiveTab] = useState("My Recipes"); const [activeTab, setActiveTab] = useState("My Recipes")
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth()
const isFocused = useIsFocused(); const isFocused = useIsFocused()
const queryClient = useQueryClient(); const queryClient = useQueryClient()
const navigation = useNavigation()
const { const {
data: userData, data: userData,
@ -35,14 +53,14 @@ export default function ProfileScreen() {
} = useQuery({ } = useQuery({
queryKey: ["auth-user"], queryKey: ["auth-user"],
queryFn: async () => { queryFn: async () => {
const { data, error } = await supabase.auth.getUser(); const { data, error } = await supabase.auth.getUser()
if (error) throw error; if (error) throw error
return data?.user; return data?.user
}, },
enabled: isAuthenticated, enabled: isAuthenticated,
staleTime: 0, staleTime: 0,
}); })
const userId = userData?.id; const userId = userData?.id
const { const {
data: profileData, data: profileData,
@ -51,115 +69,186 @@ export default function ProfileScreen() {
} = useQuery({ } = useQuery({
queryKey: ["profile", userId], queryKey: ["profile", userId],
queryFn: async () => { queryFn: async () => {
if (!userId) throw new Error("No user id"); if (!userId) throw new Error("No user id")
return getProfile(userId); return getProfile(userId)
}, },
enabled: !!userId, enabled: !!userId,
staleTime: 0, staleTime: 0,
subscribed: isFocused, subscribed: isFocused,
}); })
// My Recipes Query
const { const {
data: foodsData, data: myRecipesData,
isLoading: isFoodsLoading, isLoading: isMyRecipesLoading,
error: foodsError, error: myRecipesError,
refetch: refetchMyRecipes,
} = useQuery({ } = useQuery({
queryKey: ["my-recipes", userId], queryKey: ["my-recipes", userId],
queryFn: async () => { queryFn: async () => {
if (!userId) throw new Error("No user id"); if (!userId) throw new Error("No user id")
return getFoods(userId); return getFoods(userId)
}, },
enabled: !!userId && activeTab === "My Recipes", enabled: !!userId,
staleTime: 0, staleTime: 1000 * 60, // 1 minute
}); })
const [modalVisible, setModalVisible] = useState(false); // Likes Query
const [editUsername, setEditUsername] = useState(""); const {
const [editImage, setEditImage] = useState<string | null>(null); data: likesData,
const [editLoading, setEditLoading] = useState(false); isLoading: isLikesLoading,
const [editError, setEditError] = useState<string | null>(null); 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 === "Bookmarks") {
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<string | null>(null)
const [editLoading, setEditLoading] = useState(false)
const [editError, setEditError] = useState<string | null>(null)
const pickImage = async () => { const pickImage = async () => {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync()
if (status !== "granted") { if (status !== "granted") {
setEditError("Permission to access media library is required."); setEditError("Permission to access media library is required.")
return; return
} }
const result = await ImagePicker.launchImageLibraryAsync({ const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"], mediaTypes: ["images"],
quality: 0.7, quality: 0.7,
allowsEditing: true, allowsEditing: true,
}); })
if (!result.canceled) { if (!result.canceled) {
setEditImage(result.assets[0].uri); setEditImage(result.assets[0].uri)
}
} }
};
const uploadImageToSupabase = async (uri: string): Promise<string> => { const uploadImageToSupabase = async (uri: string): Promise<string> => {
const fileName = `${userId}/${uuid.v4()}.jpg`; const fileName = `${userId}/${uuid.v4()}.jpg`
const response = await fetch(uri); const response = await fetch(uri)
const blob = await response.blob(); const blob = await response.blob()
const { error: uploadError } = await supabase.storage const { error: uploadError } = await supabase.storage.from("avatars").upload(fileName, blob, {
.from("avatars")
.upload(fileName, blob, {
contentType: "image/jpeg", contentType: "image/jpeg",
upsert: true, upsert: true,
}); })
if (uploadError) throw uploadError; if (uploadError) throw uploadError
const { data } = supabase.storage.from("avatars").getPublicUrl(fileName); const { data } = supabase.storage.from("avatars").getPublicUrl(fileName)
return data.publicUrl; return data.publicUrl
}; }
const handleSaveProfile = async () => { const handleSaveProfile = async () => {
setEditLoading(true); setEditLoading(true)
setEditError(null); setEditError(null)
try { 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) { if (editImage && editImage !== avatarUrl) {
avatarUrl = await uploadImageToSupabase(editImage); avatarUrl = await uploadImageToSupabase(editImage)
} }
const { error: updateError } = await updateProfile( const { error: updateError } = await updateProfile(userId!, editUsername.trim(), avatarUrl)
userId!, if (updateError) throw updateError
editUsername.trim(),
avatarUrl
);
if (updateError) throw updateError;
setModalVisible(false); setModalVisible(false)
await queryClient.invalidateQueries({ queryKey: ["profile", userId] }); await queryClient.invalidateQueries({ queryKey: ["profile", userId] })
} catch (err: any) { } catch (err: any) {
setEditError(err.message || "Failed to update profile"); setEditError(err.message || "Failed to update profile")
} finally { } 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 "Bookmarks":
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) { if (isUserLoading) {
return ( return (
<SafeAreaView className="flex-1 justify-center items-center bg-white"> <SafeAreaView className="flex-1 justify-center items-center bg-white">
<ActivityIndicator size="large" color="#bb0718" /> <ActivityIndicator size="large" color="#bb0718" />
</SafeAreaView> </SafeAreaView>
); )
} }
if (userError) { if (userError) {
return ( return (
<SafeAreaView className="flex-1 justify-center items-center bg-white px-4"> <SafeAreaView className="flex-1 justify-center items-center bg-white px-4">
<Text className="text-red-600 font-bold text-center"> <Text className="text-red-600 font-bold text-center">{userError.message || "Failed to load user data."}</Text>
{userError.message || "Failed to load user data."}
</Text>
</SafeAreaView> </SafeAreaView>
); )
} }
return ( return (
@ -179,46 +268,31 @@ export default function ProfileScreen() {
{isLoading ? ( {isLoading ? (
<ActivityIndicator size="small" color="#bb0718" /> <ActivityIndicator size="small" color="#bb0718" />
) : error ? ( ) : error ? (
<Text className="text-red-600 font-bold mb-3"> <Text className="text-red-600 font-bold mb-3">{error.message || error.toString()}</Text>
{error.message || error.toString()}
</Text>
) : ( ) : (
<Text className="text-xl font-bold mb-3"> <Text className="text-xl font-bold mb-3">{profileData?.data?.username ?? "-"}</Text>
{profileData?.data?.username ?? "-"}
</Text>
)} )}
<TouchableOpacity <TouchableOpacity
className="bg-red-600 py-2 px-10 rounded-lg" className="bg-red-600 py-2 px-10 rounded-lg"
onPress={() => { onPress={() => {
setEditUsername(profileData?.data?.username ?? ""); setEditUsername(profileData?.data?.username ?? "")
setEditImage(profileData?.data?.avatar_url ?? null); setEditImage(profileData?.data?.avatar_url ?? null)
setEditError(null); setEditError(null)
setModalVisible(true); setModalVisible(true)
}} }}
> >
<Text className="text-white font-bold">Edit</Text> <Text className="text-white font-bold">Edit</Text>
</TouchableOpacity> </TouchableOpacity>
{/* Edit Modal */} {/* Edit Modal */}
<Modal <Modal visible={modalVisible} animationType="slide" transparent onRequestClose={() => setModalVisible(false)}>
visible={modalVisible}
animationType="slide"
transparent
onRequestClose={() => setModalVisible(false)}
>
<View className="flex-1 justify-center items-center bg-black bg-opacity-40"> <View className="flex-1 justify-center items-center bg-black bg-opacity-40">
<View className="bg-white rounded-xl p-6 w-11/12 max-w-md shadow-lg"> <View className="bg-white rounded-xl p-6 w-11/12 max-w-md shadow-lg">
<Text className="text-lg font-bold mb-4 text-center"> <Text className="text-lg font-bold mb-4 text-center">Edit Profile</Text>
Edit Profile
</Text>
<Pressable className="items-center mb-4" onPress={pickImage}> <Pressable className="items-center mb-4" onPress={pickImage}>
<Image <Image
source={ source={editImage ? { uri: editImage } : require("@/assets/images/placeholder-food.jpg")}
editImage
? { uri: editImage }
: require("@/assets/images/placeholder-food.jpg")
}
className="w-24 h-24 rounded-full mb-2 bg-gray-200" className="w-24 h-24 rounded-full mb-2 bg-gray-200"
/> />
<Text className="text-blue-600 underline">Change Photo</Text> <Text className="text-blue-600 underline">Change Photo</Text>
@ -231,11 +305,7 @@ export default function ProfileScreen() {
onChangeText={setEditUsername} onChangeText={setEditUsername}
placeholder="Enter new username" placeholder="Enter new username"
/> />
{editError && ( {editError && <Text className="text-red-600 mb-2 text-center">{editError}</Text>}
<Text className="text-red-600 mb-2 text-center">
{editError}
</Text>
)}
<View className="flex-row justify-between mt-2"> <View className="flex-row justify-between mt-2">
<TouchableOpacity <TouchableOpacity
@ -263,61 +333,52 @@ export default function ProfileScreen() {
</View> </View>
{/* Tab Navigation */} {/* Tab Navigation */}
<View className="flex-row justify-around py-3"> <View className="flex-row justify-around py-3 border-b border-gray-200">
{["My Recipes", "Likes", "Saved"].map((tab) => ( {["My Recipes", "Likes", "Bookmarks"].map((tab) => (
<TouchableOpacity <TouchableOpacity
key={tab} key={tab}
className={`py-2 px-4 ${ className={`py-2 px-4 ${activeTab === tab ? "border-b-2 border-[#333]" : ""}`}
activeTab === tab ? "border-b-2 border-[#333]" : "" onPress={() => handleTabChange(tab)}
}`}
onPress={() => setActiveTab(tab)}
> >
<Text className="font-medium">{tab}</Text> <Text className={`font-medium ${activeTab === tab ? "font-bold" : ""}`}>{tab}</Text>
</TouchableOpacity> </TouchableOpacity>
))} ))}
</View> </View>
<View className="h-px bg-[#EEEEEE] mx-4" /> {/* Tab Content */}
{isActiveLoading ? (
{/* Recipes */} <View className="flex-1 items-center justify-center py-8">
{activeTab === "My Recipes" && ( <ActivityIndicator size="small" color="#bb0718" />
</View>
) : activeError ? (
<View className="flex-1 items-center justify-center py-8">
<Text className="text-red-600 font-bold text-center">{activeError.message || "Failed to load data"}</Text>
</View>
) : !activeData?.data?.length ? (
<View className="flex-1 items-center justify-center py-8">
<Text className="text-gray-400 font-medium text-center">No items found</Text>
</View>
) : (
<View className="flex-row flex-wrap p-2"> <View className="flex-row flex-wrap p-2">
{isFoodsLoading ? ( {activeData.data.map((item: Food) => (
<ActivityIndicator <TouchableOpacity
size="small" key={item.id}
color="#bb0718" className="w-1/2 p-2 relative"
style={{ marginTop: 20 }} onPress={() => handleFoodPress(item.id)}
/> activeOpacity={0.7}
) : foodsError ? ( >
<Text className="text-red-600 font-bold p-4">
{foodsError.message || foodsError.toString()}
</Text>
) : foodsData?.data?.length ? (
foodsData.data.map((item) => (
<View key={item.id} className="w-1/2 p-2 relative">
<Image <Image
source={ source={item.image_url ? { uri: item.image_url } : require("@/assets/images/placeholder-food.jpg")}
item.image_url
? { uri: item.image_url }
: require("@/assets/images/placeholder-food.jpg")
}
className="w-full h-[120px] rounded-lg" className="w-full h-[120px] rounded-lg"
/> />
<View className="absolute bottom-4 left-4 py-1 px-2 rounded bg-opacity-90 bg-white/80"> <View className="absolute bottom-4 left-4 py-1 px-2 rounded bg-opacity-90 bg-white/80">
<Text className="text-[#333] font-bold text-xs"> <Text className="text-[#333] font-bold text-xs">{item.name}</Text>
{item.name}
</Text>
</View> </View>
</View> </TouchableOpacity>
)) ))}
) : (
<Text className="text-gray-400 font-bold p-4">
No recipes found.
</Text>
)}
</View> </View>
)} )}
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
); )
} }

View File

@ -3,6 +3,8 @@ import { View, Text, Image, TouchableOpacity, ScrollView, SafeAreaView, Activity
import { Feather, FontAwesome } from '@expo/vector-icons'; import { Feather, FontAwesome } from '@expo/vector-icons';
import { useLocalSearchParams, router } from 'expo-router'; import { useLocalSearchParams, router } from 'expo-router';
import { useAuth } from '../../context/auth-context'; import { useAuth } from '../../context/auth-context';
import { supabase } from '../../services/supabase';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getFoods, getIngredients, getNutrients } from '../../services/data/foods'; import { getFoods, getIngredients, getNutrients } from '../../services/data/foods';
import { import {
createLike, createLike,
@ -19,32 +21,20 @@ import {
} from '../../services/data/forum'; } from '../../services/data/forum';
import { getProfile } from '../../services/data/profile'; import { getProfile } from '../../services/data/profile';
import { Food, Ingredient, Nutrient, FoodComment, Profile } from '../../types/index'; import { Food, Ingredient, Nutrient, FoodComment, Profile } from '../../types/index';
import { supabase } from '../../services/supabase'; import { queryKeys, useLikeMutation, useSaveMutation } from '../../hooks/use-foods';
export default function PostDetailScreen() { export default function PostDetailScreen() {
const { id } = useLocalSearchParams(); const { id } = useLocalSearchParams();
const foodId = typeof id === 'string' ? id : ''; const foodId = typeof id === 'string' ? id : '';
const queryClient = useQueryClient();
console.log('Post detail screen - Food ID:', foodId); console.log('Post detail screen - Food ID:', foodId);
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const [currentUserId, setCurrentUserId] = useState<string | null>(null); const [currentUserId, setCurrentUserId] = useState<string | null>(null);
const [food, setFood] = useState<Food | null>(null);
const [foodCreator, setFoodCreator] = useState<Profile | null>(null);
const [ingredients, setIngredients] = useState<Ingredient[]>([]);
const [nutrients, setNutrients] = useState<Nutrient | null>(null);
const [comments, setComments] = useState<FoodComment[]>([]);
const [loading, setLoading] = useState(true);
const [isLiked, setIsLiked] = useState(false);
const [isSaved, setIsSaved] = useState(false);
const [showReviews, setShowReviews] = useState(true);
const [commentText, setCommentText] = useState(''); const [commentText, setCommentText] = useState('');
const [submittingComment, setSubmittingComment] = useState(false); const [submittingComment, setSubmittingComment] = useState(false);
const [stats, setStats] = useState({ const [showReviews, setShowReviews] = useState(true);
likes: 0,
saves: 0,
comments: 0
});
// Get current user ID from Supabase session // Get current user ID from Supabase session
useEffect(() => { useEffect(() => {
@ -62,14 +52,128 @@ export default function PostDetailScreen() {
getCurrentUser(); getCurrentUser();
}, [isAuthenticated]); }, [isAuthenticated]);
useEffect(() => { // Fetch food details
if (foodId) { const {
console.log('Loading food details for ID:', foodId); data: food,
loadFoodDetails(); isLoading: isLoadingFood,
} else { error: foodError
console.error('No food ID provided'); } = useQuery({
} queryKey: queryKeys.foodDetails(foodId),
}, [foodId]); queryFn: async () => {
const { data, error } = await supabase
.from('foods')
.select('*')
.eq('id', foodId)
.single();
if (error) throw error;
return {
...data,
description: data.description || '',
ingredient_count: data.ingredient_count ?? 0,
calories: data.calories ?? 0,
image_url: data.image_url || '',
};
},
enabled: !!foodId,
});
// Fetch food creator
const {
data: foodCreator,
isLoading: isLoadingCreator
} = useQuery({
queryKey: ['food-creator', food?.created_by],
queryFn: async () => {
if (!food?.created_by) return null;
const { data, error } = await getProfile(food.created_by);
if (error) throw error;
return data;
},
enabled: !!food?.created_by,
});
// Fetch food stats
const {
data: stats = { likes: 0, saves: 0, comments: 0 },
isLoading: isLoadingStats,
refetch: refetchStats
} = useQuery({
queryKey: ['food-stats', foodId],
queryFn: async () => {
const [likesRes, savesRes, commentsRes] = await Promise.all([
getLikesCount(foodId),
getSavesCount(foodId),
getCommentsCount(foodId)
]);
return {
likes: likesRes.count || 0,
saves: savesRes.count || 0,
comments: commentsRes.count || 0
};
},
enabled: !!foodId,
});
// Fetch user interactions
const {
data: interactions = { liked: false, saved: false },
isLoading: isLoadingInteractions,
refetch: refetchInteractions
} = useQuery({
queryKey: ['user-interactions', foodId, currentUserId],
queryFn: async () => {
if (!currentUserId) return { liked: false, saved: false };
const [likedRes, savedRes] = await Promise.all([
checkUserLiked(foodId, currentUserId),
checkUserSaved(foodId, currentUserId)
]);
return {
liked: !!likedRes.data,
saved: !!savedRes.data
};
},
enabled: !!foodId && !!currentUserId,
});
// Fetch comments
const {
data: comments = [],
isLoading: isLoadingComments,
refetch: refetchComments
} = useQuery({
queryKey: queryKeys.foodComments(foodId),
queryFn: async () => {
const { data, error } = await getComments(foodId);
if (error) throw error;
return data || [];
},
enabled: !!foodId,
});
// Set up mutations
const likeMutation = useLikeMutation();
const saveMutation = useSaveMutation();
const commentMutation = useMutation({
mutationFn: async ({ foodId, userId, content }: { foodId: string, userId: string, content: string }) => {
return createComment(foodId, userId, content);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.foodComments(foodId) });
queryClient.invalidateQueries({ queryKey: ['food-stats', foodId] });
setCommentText('');
},
});
// Set up real-time subscription for comments // Set up real-time subscription for comments
useEffect(() => { useEffect(() => {
@ -84,167 +188,55 @@ export default function PostDetailScreen() {
schema: 'public', schema: 'public',
table: 'food_comments', table: 'food_comments',
filter: `food_id=eq.${foodId}` filter: `food_id=eq.${foodId}`
}, (payload) => { }, () => {
console.log('Comment change detected:', payload); console.log('Comment change detected, refreshing comments');
// Refresh comments when changes occur refetchComments();
refreshComments(); refetchStats();
}) })
.subscribe(); .subscribe();
return () => { return () => {
supabase.removeChannel(subscription); supabase.removeChannel(subscription);
}; };
}, [foodId]); }, [foodId, refetchComments, refetchStats]);
// Check if user has liked/saved when user ID changes // Set up real-time subscription for likes and saves
useEffect(() => { useEffect(() => {
if (foodId && currentUserId && food) { if (!foodId) return;
checkUserInteractions();
}
}, [currentUserId, foodId, food]);
const checkUserInteractions = async () => { const likesSubscription = supabase
if (!currentUserId || !foodId) return; .channel(`food_likes:${foodId}`)
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'food_likes',
filter: `food_id=eq.${foodId}`
}, () => {
console.log('Like change detected, refreshing stats and interactions');
refetchStats();
refetchInteractions();
})
.subscribe();
try { const savesSubscription = supabase
console.log('Checking user interactions with user ID:', currentUserId); .channel(`food_saves:${foodId}`)
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'food_saves',
filter: `food_id=eq.${foodId}`
}, () => {
console.log('Save change detected, refreshing stats and interactions');
refetchStats();
refetchInteractions();
})
.subscribe();
const [likedRes, savedRes] = await Promise.all([ return () => {
checkUserLiked(foodId, currentUserId), supabase.removeChannel(likesSubscription);
checkUserSaved(foodId, currentUserId) supabase.removeChannel(savesSubscription);
]);
console.log('User liked:', !!likedRes.data);
console.log('User saved:', !!savedRes.data);
setIsLiked(!!likedRes.data);
setIsSaved(!!savedRes.data);
} catch (error) {
console.error('Error checking user interactions:', error);
}
};
const refreshComments = async () => {
if (!foodId) {
console.error('Cannot refresh comments: No food ID');
return;
}
try {
console.log(`Refreshing comments for food_id: ${foodId}`);
const { data: commentsData, error } = await getComments(foodId);
if (error) {
console.error('Error refreshing comments:', error);
return;
}
if (commentsData) {
console.log(`Refreshed ${commentsData.length} comments for food_id: ${foodId}`);
setComments(commentsData);
}
const { count } = await getCommentsCount(foodId);
setStats(prev => ({ ...prev, comments: count || 0 }));
} catch (error) {
console.error('Error refreshing comments:', error);
}
};
const loadFoodDetails = async () => {
if (!foodId) {
console.error('Cannot load food details: No food ID');
return;
}
setLoading(true);
try {
console.log('Loading food details for ID:', foodId);
// Get specific food by ID
const { data: foodData, error: foodError } = await supabase
.from('foods')
.select('*')
.eq('id', foodId)
.single();
if (foodError) {
console.error('Error loading food:', foodError);
return;
}
if (foodData) {
const foodItem = {
...foodData,
description: foodData.description || '',
ingredient_count: foodData.ingredient_count ?? 0,
calories: foodData.calories ?? 0,
image_url: foodData.image_url || '',
};
console.log('Food loaded:', foodItem.name);
setFood(foodItem);
// Get food creator profile
if (foodItem.created_by) {
console.log('Loading creator profile for:', foodItem.created_by);
const { data: creatorProfile } = await getProfile(foodItem.created_by);
if (creatorProfile) {
setFoodCreator(creatorProfile);
}
}
// Get ingredients
const { data: ingredientsData, error: ingredientsError } = await getIngredients(foodId);
if (!ingredientsError && ingredientsData) {
setIngredients(ingredientsData);
}
// Get nutrients
const { data: nutrientsData, error: nutrientsError } = await getNutrients(foodId);
if (!nutrientsError && nutrientsData) {
setNutrients(nutrientsData);
}
// Get comments for this specific food ID
const { data: commentsData, error: commentsError } = await getComments(foodId);
if (commentsError) {
console.error('Error loading comments:', commentsError);
} else if (commentsData) {
console.log(`Loaded ${commentsData.length} comments for food_id: ${foodId}`);
setComments(commentsData);
}
// Get stats
const [likesRes, savesRes, commentsRes] = await Promise.all([
getLikesCount(foodId),
getSavesCount(foodId),
getCommentsCount(foodId)
]);
console.log('Stats loaded:', {
likes: likesRes.count || 0,
saves: savesRes.count || 0,
comments: commentsRes.count || 0
});
setStats({
likes: likesRes.count || 0,
saves: savesRes.count || 0,
comments: commentsRes.count || 0
});
}
} catch (error) {
console.error('Error loading food details:', error);
Alert.alert('Error', 'Failed to load food details. Please try again.');
} finally {
setLoading(false);
}
}; };
}, [foodId, refetchStats, refetchInteractions]);
const handleLike = async () => { const handleLike = async () => {
if (!isAuthenticated || !currentUserId || !food) { if (!isAuthenticated || !currentUserId || !food) {
@ -253,42 +245,13 @@ export default function PostDetailScreen() {
} }
try { try {
console.log('Toggling like with user ID:', currentUserId, 'and food ID:', foodId); likeMutation.mutate({
foodId,
// Optimistically update UI userId: currentUserId,
setIsLiked(!isLiked); isLiked: interactions.liked
setStats(prev => ({ });
...prev,
likes: isLiked ? Math.max(0, prev.likes - 1) : prev.likes + 1
}));
if (isLiked) {
const { error } = await deleteLike(foodId, currentUserId);
if (error) {
console.error('Error deleting like:', error);
// Revert optimistic update if there's an error
setIsLiked(true);
setStats(prev => ({ ...prev, likes: prev.likes + 1 }));
Alert.alert('Error', 'Failed to unlike. Please try again.');
}
} else {
const { error } = await createLike(foodId, currentUserId);
if (error) {
console.error('Error creating like:', error);
// Revert optimistic update if there's an error
setIsLiked(false);
setStats(prev => ({ ...prev, likes: Math.max(0, prev.likes - 1) }));
Alert.alert('Error', 'Failed to like. Please try again.');
}
}
} catch (error) { } catch (error) {
console.error('Error toggling like:', error); console.error('Error toggling like:', error);
// Revert optimistic update if there's an error
setIsLiked(!isLiked);
setStats(prev => ({
...prev,
likes: !isLiked ? Math.max(0, prev.likes - 1) : prev.likes + 1
}));
Alert.alert('Error', 'Failed to update like. Please try again.'); Alert.alert('Error', 'Failed to update like. Please try again.');
} }
}; };
@ -300,42 +263,13 @@ export default function PostDetailScreen() {
} }
try { try {
console.log('Toggling save with user ID:', currentUserId, 'and food ID:', foodId); saveMutation.mutate({
foodId,
// Optimistically update UI userId: currentUserId,
setIsSaved(!isSaved); isSaved: interactions.saved
setStats(prev => ({ });
...prev,
saves: isSaved ? Math.max(0, prev.saves - 1) : prev.saves + 1
}));
if (isSaved) {
const { error } = await deleteSave(foodId, currentUserId);
if (error) {
console.error('Error deleting save:', error);
// Revert optimistic update if there's an error
setIsSaved(true);
setStats(prev => ({ ...prev, saves: prev.saves + 1 }));
Alert.alert('Error', 'Failed to unsave. Please try again.');
}
} else {
const { error } = await createSave(foodId, currentUserId);
if (error) {
console.error('Error creating save:', error);
// Revert optimistic update if there's an error
setIsSaved(false);
setStats(prev => ({ ...prev, saves: Math.max(0, prev.saves - 1) }));
Alert.alert('Error', 'Failed to save. Please try again.');
}
}
} catch (error) { } catch (error) {
console.error('Error toggling save:', error); console.error('Error toggling save:', error);
// Revert optimistic update if there's an error
setIsSaved(!isSaved);
setStats(prev => ({
...prev,
saves: !isSaved ? Math.max(0, prev.saves - 1) : prev.saves + 1
}));
Alert.alert('Error', 'Failed to update save. Please try again.'); Alert.alert('Error', 'Failed to update save. Please try again.');
} }
}; };
@ -350,23 +284,11 @@ export default function PostDetailScreen() {
setSubmittingComment(true); setSubmittingComment(true);
try { try {
console.log('Submitting comment with user ID:', currentUserId, 'and food ID:', foodId); await commentMutation.mutateAsync({
foodId,
const { error } = await createComment(foodId, currentUserId, commentText.trim()); userId: currentUserId,
content: commentText.trim()
if (error) { });
console.error('Error creating comment:', error);
Alert.alert('Error', 'Failed to submit comment. Please try again.');
return;
}
// Clear comment text
setCommentText('');
// Refresh comments
await refreshComments();
console.log('Comment submitted successfully');
} catch (error) { } catch (error) {
console.error('Error submitting comment:', error); console.error('Error submitting comment:', error);
Alert.alert('Error', 'Failed to submit comment. Please try again.'); Alert.alert('Error', 'Failed to submit comment. Please try again.');
@ -375,7 +297,9 @@ export default function PostDetailScreen() {
} }
}; };
if (loading) { const isLoading = isLoadingFood || isLoadingCreator || isLoadingStats || isLoadingInteractions || isLoadingComments;
if (isLoading) {
return ( return (
<SafeAreaView className="flex-1 bg-white"> <SafeAreaView className="flex-1 bg-white">
<View className="flex-1 items-center justify-center"> <View className="flex-1 items-center justify-center">
@ -385,7 +309,7 @@ export default function PostDetailScreen() {
); );
} }
if (!food) { if (foodError || !food) {
return ( return (
<SafeAreaView className="flex-1 bg-white"> <SafeAreaView className="flex-1 bg-white">
<View className="flex-1 items-center justify-center"> <View className="flex-1 items-center justify-center">
@ -436,13 +360,13 @@ export default function PostDetailScreen() {
) : ( ) : (
<View className="w-full h-full bg-gray-300 items-center justify-center"> <View className="w-full h-full bg-gray-300 items-center justify-center">
<Text className="text-lg font-bold text-gray-600"> <Text className="text-lg font-bold text-gray-600">
{foodCreator?.username?.charAt(0).toUpperCase() || '?'} {foodCreator?.username?.charAt(0).toUpperCase() || food.created_by?.charAt(0).toUpperCase() || '?'}
</Text> </Text>
</View> </View>
)} )}
</View> </View>
<Text className="ml-3 text-lg font-bold"> <Text className="ml-3 text-lg font-bold">
{foodCreator?.username || foodCreator?.full_name || 'Unknown Chef'} {foodCreator?.username || foodCreator?.full_name || 'Chef'}
</Text> </Text>
</View> </View>
<View className="flex-row items-center"> <View className="flex-row items-center">
@ -471,9 +395,13 @@ export default function PostDetailScreen() {
{/* Interaction buttons */} {/* Interaction buttons */}
<View className="flex-row justify-between px-4 py-4 border-b border-gray-200"> <View className="flex-row justify-between px-4 py-4 border-b border-gray-200">
<TouchableOpacity className="flex-row items-center">
<Feather name="message-square" size={22} color="#333" /> <TouchableOpacity
<Text className="ml-2 text-lg">{stats.comments}</Text> className="flex-row items-center"
onPress={handleLike}
>
<Feather name="heart" size={22} color={interactions.liked ? "#E91E63" : "#333"} />
<Text className="ml-2 text-lg">{stats.likes}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity className="flex-row items-center"> <TouchableOpacity className="flex-row items-center">
@ -481,16 +409,9 @@ export default function PostDetailScreen() {
<Text className="ml-2 text-lg">{stats.comments}</Text> <Text className="ml-2 text-lg">{stats.comments}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity
className="flex-row items-center"
onPress={handleLike}
>
<Feather name="heart" size={22} color={isLiked ? "#E91E63" : "#333"} />
<Text className="ml-2 text-lg">{stats.likes}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={handleSave}> <TouchableOpacity onPress={handleSave}>
<Feather name="bookmark" size={22} color={isSaved ? "#ffd60a" : "#333"} /> <Feather name="bookmark" size={22} color={interactions.saved ? "#ffd60a" : "#333"} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -518,14 +439,14 @@ export default function PostDetailScreen() {
) : ( ) : (
<View className="w-full h-full bg-gray-300 items-center justify-center"> <View className="w-full h-full bg-gray-300 items-center justify-center">
<Text className="text-base font-bold text-gray-600"> <Text className="text-base font-bold text-gray-600">
{comment.user?.username?.charAt(0).toUpperCase() || '?'} {comment.user?.username?.charAt(0).toUpperCase() || comment.user_id?.charAt(0).toUpperCase() || '?'}
</Text> </Text>
</View> </View>
)} )}
</View> </View>
<View className="flex-row items-center justify-between flex-1"> <View className="flex-row items-center justify-between flex-1">
<Text className="font-bold"> <Text className="font-bold">
{comment.user?.username || comment.user?.full_name || 'Unknown User'} {comment.user?.username || comment.user?.full_name || 'User'}
</Text> </Text>
<Text className="text-gray-500 text-xs"> <Text className="text-gray-500 text-xs">
{new Date(comment.created_at).toLocaleDateString()} {new Date(comment.created_at).toLocaleDateString()}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

BIN
assets/images/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

287
hooks/use-foods.ts Normal file
View File

@ -0,0 +1,287 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getFoods } from '../services/data/foods';
import {
getLikesCount,
getSavesCount,
getCommentsCount,
createLike,
deleteLike,
createSave,
deleteSave,
checkUserLiked,
checkUserSaved
} from '../services/data/forum';
import { getProfile } from '../services/data/profile';
import { Food, Profile } from '../types/index';
// Query keys
export const queryKeys = {
foods: 'foods',
foodStats: 'food-stats',
foodCreators: 'food-creators',
userInteractions: 'user-interactions',
foodDetails: (id: string) => ['food-details', id],
foodComments: (id: string) => ['food-comments', id],
};
// Hook to fetch foods
export function useFoods(category?: string, search?: string, sort?: string) {
return useQuery({
queryKey: [queryKeys.foods, category, search, sort],
queryFn: async () => {
const { data, error } = await getFoods(category, true, search);
if (error) {
throw error;
}
if (!data) {
return [];
}
let sortedData = [...data];
if (sort === 'rating') {
sortedData.sort((a, b) => (b.calories ?? 0) - (a.calories ?? 0));
} else if (sort === 'newest') {
sortedData.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
} else if (sort === 'best') {
sortedData.sort((a, b) => (b.ingredient_count ?? 0) - (a.ingredient_count ?? 0));
}
return sortedData.map(food => ({
...food,
description: food.description || '',
ingredient_count: food.ingredient_count ?? 0,
calories: food.calories ?? 0,
image_url: food.image_url || '',
}));
},
});
}
// Hook to fetch food stats
export function useFoodStats(foodIds: string[]) {
return useQuery({
queryKey: [queryKeys.foodStats, foodIds],
queryFn: async () => {
if (!foodIds.length) return {};
const statsPromises = foodIds.map(async (foodId) => {
const [likesRes, savesRes, commentsRes] = await Promise.all([
getLikesCount(foodId),
getSavesCount(foodId),
getCommentsCount(foodId)
]);
return {
foodId,
likes: likesRes.count || 0,
saves: savesRes.count || 0,
comments: commentsRes.count || 0
};
});
const stats = await Promise.all(statsPromises);
return stats.reduce((acc, stat) => {
acc[stat.foodId] = {
likes: stat.likes,
saves: stat.saves,
comments: stat.comments
};
return acc;
}, {} as Record<string, { likes: number, saves: number, comments: number }>);
},
enabled: foodIds.length > 0,
});
}
// Hook to fetch food creators
export function useFoodCreators(creatorIds: string[]) {
return useQuery({
queryKey: [queryKeys.foodCreators, creatorIds],
queryFn: async () => {
if (!creatorIds.length) return {};
const uniqueCreatorIds = [...new Set(creatorIds)];
const creatorProfiles: Record<string, Profile> = {};
for (const creatorId of uniqueCreatorIds) {
const { data: profile } = await getProfile(creatorId);
if (profile) {
creatorProfiles[creatorId] = profile;
}
}
return creatorProfiles;
},
enabled: creatorIds.length > 0,
});
}
// Hook to fetch user interactions
export function useUserInteractions(foodIds: string[], userId: string | null) {
return useQuery({
queryKey: [queryKeys.userInteractions, foodIds, userId],
queryFn: async () => {
if (!foodIds.length || !userId) return {};
const interactionsPromises = foodIds.map(async (foodId) => {
const [likedRes, savedRes] = await Promise.all([
checkUserLiked(foodId, userId),
checkUserSaved(foodId, userId)
]);
return {
foodId,
liked: !!likedRes.data,
saved: !!savedRes.data
};
});
const interactions = await Promise.all(interactionsPromises);
return interactions.reduce((acc, interaction) => {
acc[interaction.foodId] = {
liked: interaction.liked,
saved: interaction.saved
};
return acc;
}, {} as Record<string, { liked: boolean, saved: boolean }>);
},
enabled: foodIds.length > 0 && !!userId,
});
}
// Hook to like/unlike a food
export function useLikeMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ foodId, userId, isLiked }: { foodId: string, userId: string, isLiked: boolean }) => {
if (isLiked) {
return deleteLike(foodId, userId);
} else {
return createLike(foodId, userId);
}
},
onMutate: async ({ foodId, isLiked }) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: [queryKeys.foodStats] });
await queryClient.cancelQueries({ queryKey: [queryKeys.userInteractions] });
// Snapshot the previous value
const previousStats = queryClient.getQueryData([queryKeys.foodStats]);
const previousInteractions = queryClient.getQueryData([queryKeys.userInteractions]);
// Optimistically update
queryClient.setQueryData([queryKeys.foodStats], (old: any) => {
if (!old) return old;
return {
...old,
[foodId]: {
...old[foodId],
likes: isLiked ? Math.max(0, old[foodId].likes - 1) : old[foodId].likes + 1
}
};
});
queryClient.setQueryData([queryKeys.userInteractions], (old: any) => {
if (!old) return old;
return {
...old,
[foodId]: {
...old[foodId],
liked: !isLiked
}
};
});
// Return a context object with the snapshotted value
return { previousStats, previousInteractions };
},
onError: (err, variables, context) => {
// If the mutation fails, use the context returned from onMutate to roll back
if (context?.previousStats) {
queryClient.setQueryData([queryKeys.foodStats], context.previousStats);
}
if (context?.previousInteractions) {
queryClient.setQueryData([queryKeys.userInteractions], context.previousInteractions);
}
},
onSettled: () => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: [queryKeys.foodStats] });
queryClient.invalidateQueries({ queryKey: [queryKeys.userInteractions] });
},
});
}
// Hook to save/unsave a food
export function useSaveMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ foodId, userId, isSaved }: { foodId: string, userId: string, isSaved: boolean }) => {
if (isSaved) {
return deleteSave(foodId, userId);
} else {
return createSave(foodId, userId);
}
},
onMutate: async ({ foodId, isSaved }) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: [queryKeys.foodStats] });
await queryClient.cancelQueries({ queryKey: [queryKeys.userInteractions] });
// Snapshot the previous value
const previousStats = queryClient.getQueryData([queryKeys.foodStats]);
const previousInteractions = queryClient.getQueryData([queryKeys.userInteractions]);
// Optimistically update
queryClient.setQueryData([queryKeys.foodStats], (old: any) => {
if (!old) return old;
return {
...old,
[foodId]: {
...old[foodId],
saves: isSaved ? Math.max(0, old[foodId].saves - 1) : old[foodId].saves + 1
}
};
});
queryClient.setQueryData([queryKeys.userInteractions], (old: any) => {
if (!old) return old;
return {
...old,
[foodId]: {
...old[foodId],
saved: !isSaved
}
};
});
// Return a context object with the snapshotted value
return { previousStats, previousInteractions };
},
onError: (err, variables, context) => {
// If the mutation fails, use the context returned from onMutate to roll back
if (context?.previousStats) {
queryClient.setQueryData([queryKeys.foodStats], context.previousStats);
}
if (context?.previousInteractions) {
queryClient.setQueryData([queryKeys.userInteractions], context.previousInteractions);
}
},
onSettled: () => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: [queryKeys.foodStats] });
queryClient.invalidateQueries({ queryKey: [queryKeys.userInteractions] });
},
});
}

View File

@ -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 }
}

View File

@ -208,6 +208,7 @@ export const checkUserSaved = async (food_id: string, user_id: string) => {
export const getComments = async (food_id: string) => { export const getComments = async (food_id: string) => {
console.log('Getting comments for food_id:', food_id); console.log('Getting comments for food_id:', food_id);
try {
const { data, error } = await supabase const { data, error } = await supabase
.from("food_comments") .from("food_comments")
.select(` .select(`
@ -233,7 +234,7 @@ export const getComments = async (food_id: string) => {
const { data: profiles } = await getProfiles(userIds); const { data: profiles } = await getProfiles(userIds);
// Add user profiles to comments // Add user profiles to comments
if (profiles) { if (profiles && profiles.length > 0) {
const profileMap = profiles.reduce((acc, profile) => { const profileMap = profiles.reduce((acc, profile) => {
acc[profile.id] = profile; acc[profile.id] = profile;
return acc; return acc;
@ -242,14 +243,19 @@ export const getComments = async (food_id: string) => {
// Attach profiles to comments // Attach profiles to comments
const commentsWithProfiles = data.map(comment => ({ const commentsWithProfiles = data.map(comment => ({
...comment, ...comment,
user: profileMap[comment.user_id] user: profileMap[comment.user_id] || null
})); }));
console.log(`Found ${commentsWithProfiles.length} comments for food_id: ${food_id}`); console.log(`Found ${commentsWithProfiles.length} comments for food_id: ${food_id}`);
return { data: commentsWithProfiles, error }; return { data: commentsWithProfiles, error: null };
} }
} }
// If no profiles were found or no comments exist, return the original data
console.log(`Found ${data?.length || 0} comments for food_id: ${food_id}`); console.log(`Found ${data?.length || 0} comments for food_id: ${food_id}`);
return { data, error }; return { data: data?.map(comment => ({ ...comment, user: null })) || [], error: null };
} catch (error) {
console.error('Error in getComments:', error);
return { data: [], error };
} }
};

48
services/data/likes.ts Normal file
View File

@ -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 }
}

View File

@ -64,55 +64,3 @@ export interface Profile {
avatar_url?: string; avatar_url?: string;
website?: string; website?: string;
} }
export interface Food {
id: string;
created_at: string;
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;
}
export interface FoodLike {
created_at: string;
user_id: string;
food_id: string;
}
export interface FoodSave {
created_at: string;
user_id: string;
food_id: string;
}
export interface FoodComment {
id: string;
created_at: string;
user_id: string;
food_id: string;
content: string;
user?: Profile; // Add user profile to comments
}
export interface Nutrient {
food_id: string;
fat_g: number;
fiber_g: number;
protein_g: number;
carbs_g: number;
created_at: string;
}
export interface Ingredient {
id: string;
food_id: string;
name: string;
emoji: string;
created_at: string;
}