diff --git a/app/(tabs)/forum.tsx b/app/(tabs)/forum.tsx index caa0986..94ebb79 100644 --- a/app/(tabs)/forum.tsx +++ b/app/(tabs)/forum.tsx @@ -1,120 +1,261 @@ -import { IconSymbol } from "@/components/ui/IconSymbol"; -import { Image } from "expo-image"; -import { - ScrollView, - Text, - TextInput, - TouchableOpacity, - View, -} from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; +import React, { useState, useEffect } from 'react'; +import { View, Text, Image, TextInput, TouchableOpacity, FlatList, SafeAreaView, ActivityIndicator } from 'react-native'; +import { Feather, FontAwesome, Ionicons } from '@expo/vector-icons'; +import { router } from 'expo-router'; +import { useAuth } from '../../context/auth-context'; +import { getFoods } from '../../services/data/foods'; +import { getLikesCount, getSavesCount, getCommentsCount } from '../../services/data/forum'; +import { Food } from '../../types/index'; + +// Categories for filtering +const categories = [ + { id: 'main', name: 'Main dish' }, + { id: 'dessert', name: 'Dessert' }, + { id: 'appetizer', name: 'Appetite' }, +]; + +// Sort options +const sortOptions = [ + { id: 'rating', name: 'Rating', icon: 'star' }, + { id: 'newest', name: 'Newest', icon: 'calendar' }, + { id: 'best', name: 'Best', icon: 'fire' }, +]; export default function ForumScreen() { - return ( - - - {/* Search Bar */} - - - - - - {/* Category Filters */} - - - Main dish - - - Dessert - - - Appetite - - - - {/* Filter Options */} - - - Rating - - - - Newest - - - - Best - - - - - {/* Post */} - - {/* User Info */} - + const { isAuthenticated } = useAuth(); + const [searchQuery, setSearchQuery] = useState(''); + const [foods, setFoods] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedCategory, setSelectedCategory] = useState(''); + const [selectedSort, setSelectedSort] = useState('rating'); + const [foodStats, setFoodStats] = useState<{[key: string]: {likes: number, saves: number, comments: number}}>({}); + + useEffect(() => { + loadFoods(); + }, [selectedCategory, selectedSort]); + + 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); + } + } catch (error) { + console.error('Error:', error); + } finally { + setLoading(false); + } + }; + + const handleSearch = (text: string) => { + setSearchQuery(text); + // Debounce search for better performance + setTimeout(() => { + loadFoods(); + }, 500); + }; + + const navigateToPostDetail = (food: Food) => { + router.push({ + pathname: '/post-detail', + params: { id: food.id } + }); + }; + + const renderFoodItem = ({ item }: { item: Food }) => { + // Get stats for this food + const stats = foodStats[item.id] || { likes: 0, saves: 0, comments: 0 }; + + // Mock data for UI elements not in the Food type + const username = 'Mr. Chef'; + const rating = 4.2; + + return ( + navigateToPostDetail(item)} + > + + {/* User info and rating */} + - - + - Mr. Chef + {username} - 4.2 - + {rating} + - - {/* Post Image */} - - - {/* Post Content */} - - - Kajjecaw - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut at - hendrerit enim. Etiam lacinia mi nec nunc ornare, vitae tempus leo - aliquet... - + + {/* Food image */} + + - - {/* Post Actions */} - - - - 3 - - - - 2 - - - - 2 - - - + + {/* Food title and description */} + + {item.name} + {item.description} + + + {/* Interaction buttons */} + + + + + {stats.comments} + + + + + {stats.comments} + + + + + {stats.likes} + + + + + - + + ); + }; + + return ( + + {/* Search Bar */} + + + + + + + + {/* Categories */} + + item.id} + renderItem={({ item }) => ( + setSelectedCategory(item.id === selectedCategory ? '' : item.id)} + > + {item.name} + + )} + /> + + + {/* Sort Options */} + + item.id} + renderItem={({ item }) => ( + setSelectedSort(item.id)} + > + {item.name} + + + )} + /> + + + {/* Food Posts */} + {loading ? ( + + + + ) : ( + item.id} + renderItem={renderFoodItem} + contentContainerStyle={{ padding: 16 }} + showsVerticalScrollIndicator={false} + /> + )} ); -} +} \ No newline at end of file diff --git a/app/post-detail.tsx b/app/post-detail.tsx new file mode 100644 index 0000000..d925dbb --- /dev/null +++ b/app/post-detail.tsx @@ -0,0 +1,361 @@ +import React, { useState, useEffect } from 'react'; +import { View, Text, Image, TouchableOpacity, ScrollView, SafeAreaView, ActivityIndicator, TextInput, KeyboardAvoidingView, Platform } from 'react-native'; +import { Feather, FontAwesome } from '@expo/vector-icons'; +import { useLocalSearchParams, router } from 'expo-router'; +import { useAuth } from '../context/auth-context'; +import { getFoods, getIngredients, getNutrients } from '../services/data/foods'; +import { + createLike, + deleteLike, + createSave, + deleteSave, + getComments, + createComment, + getLikesCount, + getSavesCount, + getCommentsCount, + checkUserLiked, + checkUserSaved +} from '../services/data/forum'; +import { Food, Ingredient, Nutrient, FoodComment } from '../types/index'; + +export default function PostDetailScreen() { + const { id } = useLocalSearchParams(); + const authContext = useAuth(); + const { isAuthenticated } = authContext || {}; // Adjust based on the actual structure of AuthContextType + const [food, setFood] = useState(null); + const [ingredients, setIngredients] = useState([]); + const [nutrients, setNutrients] = useState(null); + const [comments, setComments] = useState([]); + 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 [submittingComment, setSubmittingComment] = useState(false); + const [stats, setStats] = useState({ + likes: 0, + saves: 0, + comments: 0 + }); + + // Mock data for UI elements + const username = 'Mr. Chef'; + const rating = 4.2; + + useEffect(() => { + if (id) { + loadFoodDetails(); + } + }, [id]); + + const loadFoodDetails = async () => { + setLoading(true); + try { + // Get food details + const { data: foodData, error: foodError } = await getFoods(undefined, undefined, undefined, 1, 0); + + if (foodError) { + console.error('Error loading food:', foodError); + return; + } + + if (foodData && foodData.length > 0) { + setFood({ + ...foodData[0], + description: foodData[0].description || '', // Ensure description is always a string + ingredient_count: foodData[0].ingredient_count ?? 0, // Provide default value for ingredient_count + calories: foodData[0].calories ?? 0, // Provide default value for calories + image_url: foodData[0].image_url || '', // Provide default value for image_url + }); + + // Get ingredients + const { data: ingredientsData, error: ingredientsError } = await getIngredients(foodData[0].id); + + if (!ingredientsError && ingredientsData) { + setIngredients(ingredientsData); + } + + // Get nutrients + const { data: nutrientsData, error: nutrientsError } = await getNutrients(foodData[0].id); + + if (!nutrientsError && nutrientsData) { + setNutrients(nutrientsData); + } + + // Get comments + const { data: commentsData, error: commentsError } = await getComments(foodData[0].id); + + if (!commentsError && commentsData) { + setComments(commentsData); + } + + // Get stats + const [likesRes, savesRes, commentsRes] = await Promise.all([ + getLikesCount(foodData[0].id), + getSavesCount(foodData[0].id), + getCommentsCount(foodData[0].id) + ]); + + setStats({ + likes: likesRes.count || 0, + saves: savesRes.count || 0, + comments: commentsRes.count || 0 + }); + + // Check if user has liked/saved + if (isAuthenticated) { + const userId = 'current-user-id'; // Replace with actual user ID + + const [likedRes, savedRes] = await Promise.all([ + checkUserLiked(foodData[0].id, userId), + checkUserSaved(foodData[0].id, userId) + ]); + + setIsLiked(!!likedRes.data); + setIsSaved(!!savedRes.data); + } + } + } catch (error) { + console.error('Error:', error); + } finally { + setLoading(false); + } + }; + + const handleLike = async () => { + if (!authContext.isAuthenticated || !food) return; + + try { + const userId = 'current-user-id'; // Replace with actual user ID + + if (isLiked) { + await deleteLike(food.id, userId); + setIsLiked(false); + setStats(prev => ({ ...prev, likes: Math.max(0, prev.likes - 1) })); + } else { + await createLike(food.id, userId); + setIsLiked(true); + setStats(prev => ({ ...prev, likes: prev.likes + 1 })); + } + } catch (error) { + console.error('Error toggling like:', error); + } + }; + + const handleSave = async () => { + if (!authContext.isAuthenticated || !food) return; + + try { + const userId = 'current-user-id'; // Replace with actual user ID + + if (isSaved) { + await deleteSave(food.id, userId); + setIsSaved(false); + setStats(prev => ({ ...prev, saves: Math.max(0, prev.saves - 1) })); + } else { + await createSave(food.id, userId); + setIsSaved(true); + setStats(prev => ({ ...prev, saves: prev.saves + 1 })); + } + } catch (error) { + console.error('Error toggling save:', error); + } + }; + + const handleSubmitComment = async () => { + if (!authContext.isAuthenticated || !food || !commentText.trim()) return; + + setSubmittingComment(true); + try { + const userId = 'current-user-id'; // Replace with actual user ID + + await createComment(food.id, userId, commentText.trim()); + + // Refresh comments + const { data: commentsData } = await getComments(food.id); + + if (commentsData) { + setComments(commentsData); + setStats(prev => ({ ...prev, comments: prev.comments + 1 })); + } + + setCommentText(''); + } catch (error) { + console.error('Error submitting comment:', error); + } finally { + setSubmittingComment(false); + } + }; + + if (loading) { + return ( + + + + + + ); + } + + if (!food) { + return ( + + + Post not found + router.back()} + > + Go Back + + + + ); + } + + return ( + + + + {/* Header */} + + router.back()} + > + + + + Post + + + + + + + {/* User info and rating */} + + + + + + {username} + + + {rating} + + + + + {/* Food image */} + + + + + {/* Food title and description */} + + {food.name} + {food.description} + 09:41 - 4/3/25 + + + {/* Interaction buttons */} + + + + {stats.comments} + + + + + {stats.comments} + + + + + {stats.likes} + + + + + + + + {/* Reviews section */} + setShowReviews(!showReviews)} + > + Review + + + + {showReviews && ( + + {comments.length > 0 ? ( + comments.map((comment) => ( + + + + + + + {comment.user_id} + + {new Date(comment.created_at).toLocaleDateString()} + + + + {comment.content} + + )) + ) : ( + No reviews yet. Be the first to comment! + )} + + )} + + {/* Bottom spacing */} + + + + {/* Comment input */} + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/app/signup.tsx b/app/signup.tsx index 4969eb2..3877119 100644 --- a/app/signup.tsx +++ b/app/signup.tsx @@ -37,7 +37,7 @@ export default function SignupScreen() { try { setIsLoading(true); // Only pass email and password to signup, as per new auth-context - await signup(email, password); + await signup(name, email); // Optionally, save name to profile after signup here in the future router.push("/login"); } catch (error) { diff --git a/services/data/forum.ts b/services/data/forum.ts index 20ddb3e..bb4b641 100644 --- a/services/data/forum.ts +++ b/services/data/forum.ts @@ -1,4 +1,5 @@ import { supabase } from "@/services/supabase"; +import { getProfile, getProfiles } from "./profile"; export const createLike = async (food_id: string, user_id: string) => { const { data, error } = await supabase.from("food_likes").insert({ food_id, user_id }); @@ -19,3 +20,92 @@ export const deleteSave = async (food_id: string, user_id: string) => { const { data, error } = await supabase.from("food_saves").delete().eq("food_id", food_id).eq("user_id", user_id); return { data, error }; } + +export const createComment = async (food_id: string, user_id: string, content: string) => { + const { data, error } = await supabase.from("food_comments").insert({ food_id, user_id, content }); + return { data, error }; +} + +export const getComments = async (food_id: string) => { + const { data, error } = await supabase + .from("food_comments") + .select(` + id, + created_at, + user_id, + food_id, + content + `) + .eq("food_id", food_id) + .order("created_at", { ascending: false }); + + if (data && data.length > 0) { + // Get unique user IDs from comments + const userIds = [...new Set(data.map(comment => comment.user_id))]; + + // Fetch profiles for these users + const { data: profiles } = await getProfiles(userIds); + + // Add user profiles to comments + if (profiles) { + const profileMap = profiles.reduce((acc, profile) => { + acc[profile.id] = profile; + return acc; + }, {} as Record); + + // Attach profiles to comments + const commentsWithProfiles = data.map(comment => ({ + ...comment, + user: profileMap[comment.user_id] + })); + + return { data: commentsWithProfiles, error }; + } + } + + return { data, error }; +} + +export const getLikesCount = async (food_id: string) => { + const { count, error } = await supabase + .from("food_likes") + .select("*", { count: "exact", head: true }) + .eq("food_id", food_id); + return { count, error }; +} + +export const getSavesCount = async (food_id: string) => { + const { count, error } = await supabase + .from("food_saves") + .select("*", { count: "exact", head: true }) + .eq("food_id", food_id); + return { count, error }; +} + +export const getCommentsCount = async (food_id: string) => { + const { count, error } = await supabase + .from("food_comments") + .select("*", { count: "exact", head: true }) + .eq("food_id", food_id); + return { count, error }; +} + +export const checkUserLiked = async (food_id: string, user_id: string) => { + const { data, error } = await supabase + .from("food_likes") + .select("*") + .eq("food_id", food_id) + .eq("user_id", user_id) + .single(); + return { data, error }; +} + +export const checkUserSaved = async (food_id: string, user_id: string) => { + const { data, error } = await supabase + .from("food_saves") + .select("*") + .eq("food_id", food_id) + .eq("user_id", user_id) + .single(); + return { data, error }; +} \ No newline at end of file diff --git a/services/data/profile.ts b/services/data/profile.ts index 066c7be..3d63711 100644 --- a/services/data/profile.ts +++ b/services/data/profile.ts @@ -10,6 +10,8 @@ export async function getProfile(userId: string): Promise<{ updated_at: any; username: any; avatar_url: any; + full_name?: any; + website?: any; } | null; error: PostgrestError | null; }> { @@ -19,7 +21,9 @@ export async function getProfile(userId: string): Promise<{ id, updated_at, username, - avatar_url + full_name, + avatar_url, + website `) .eq('id', userId) .single() @@ -33,7 +37,9 @@ export async function getProfile(userId: string): Promise<{ export async function updateProfile( userId: string, username?: string | null, - avatar_url?: string | null + avatar_url?: string | null, + full_name?: string | null, + website?: string | null ): Promise<{ data: any; error: PostgrestError | null }> { const updateData: Record = {} if (username !== undefined && username !== null) { @@ -42,6 +48,12 @@ export async function updateProfile( if (avatar_url !== undefined && avatar_url !== null) { updateData.avatar_url = avatar_url } + if (full_name !== undefined && full_name !== null) { + updateData.full_name = full_name + } + if (website !== undefined && website !== null) { + updateData.website = website + } const { data, error } = await supabase .from('profiles') @@ -52,3 +64,28 @@ export async function updateProfile( return { data, error } } + +/** + * Gets multiple user profiles by their IDs + */ +export async function getProfiles(userIds: string[]): Promise<{ + data: { + id: any; + username: any; + avatar_url: any; + full_name?: any; + }[] | null; + error: PostgrestError | null; +}> { + const { data, error } = await supabase + .from('profiles') + .select(` + id, + username, + full_name, + avatar_url + `) + .in('id', userIds) + + return { data, error } +} \ No newline at end of file diff --git a/types/index.ts b/types/index.ts new file mode 100644 index 0000000..e8db137 --- /dev/null +++ b/types/index.ts @@ -0,0 +1,118 @@ +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; // Adding content field for comments +} + +export interface User { + id: string; + username: string; + avatar_url?: string; + rating?: number; +} + +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; +} + +export interface Profile { + id: string; + updated_at: string; + username: string; + full_name?: string; + avatar_url?: 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; +} \ No newline at end of file