From 576914de608ef81adeb6594876d0e908246f9802 Mon Sep 17 00:00:00 2001 From: Tantikon Phasanphaengsi Date: Sun, 11 May 2025 01:19:30 +0700 Subject: [PATCH 1/3] feat: sorting newest and like --- app/(tabs)/forum.tsx | 308 +++++++++++++++++++------------------------ hooks/use-foods.ts | 17 ++- 2 files changed, 151 insertions(+), 174 deletions(-) diff --git a/app/(tabs)/forum.tsx b/app/(tabs)/forum.tsx index cbf25b5..9e53617 100644 --- a/app/(tabs)/forum.tsx +++ b/app/(tabs)/forum.tsx @@ -1,153 +1,146 @@ -import React, { useState, useEffect } from 'react'; -import { View, Text, Image, TextInput, TouchableOpacity, FlatList, SafeAreaView, ActivityIndicator, Alert } from 'react-native'; -import { Feather, FontAwesome } from '@expo/vector-icons'; -import { router, useFocusEffect } from 'expo-router'; -import { useAuth } from '../../context/auth-context'; -import { supabase } from '../../services/supabase'; -import { - useFoods, - useFoodStats, - useFoodCreators, +"use client" + +import React, { useState, useEffect } from "react" +import { + View, + Text, + Image, + TextInput, + TouchableOpacity, + FlatList, + SafeAreaView, + ActivityIndicator, + Alert, +} from "react-native" +import { Feather, FontAwesome } from "@expo/vector-icons" +import { router, useFocusEffect } from "expo-router" +import { useAuth } from "../../context/auth-context" +import { supabase } from "../../services/supabase" +import { + useFoods, + useFoodStats, + useFoodCreators, useUserInteractions, useLikeMutation, - useSaveMutation -} from '../../hooks/use-foods'; - -// Categories for filtering -const categories = [ - { id: 'main', name: 'Main dish' }, - { id: 'dessert', name: 'Dessert' }, - { id: 'appetizer', name: 'Appetite' }, -]; + useSaveMutation, +} from "../../hooks/use-foods" // Sort options const sortOptions = [ - { id: 'rating', name: 'Rating', icon: 'star' }, - { id: 'newest', name: 'Newest', icon: 'calendar' }, - { id: 'best', name: 'Best', icon: 'fire' }, -]; + { id: "newest", name: "Newest", icon: "calendar" }, + { id: "like_desc", name: "Most Liked", icon: "heart" }, +] export default function ForumScreen() { - const { isAuthenticated } = useAuth(); - const [currentUserId, setCurrentUserId] = useState(null); - const [searchQuery, setSearchQuery] = useState(''); - const [selectedCategory, setSelectedCategory] = useState(''); - const [selectedSort, setSelectedSort] = useState('rating'); - + const { isAuthenticated } = useAuth() + const [currentUserId, setCurrentUserId] = useState(null) + const [searchQuery, setSearchQuery] = useState("") + const [selectedCategory, setSelectedCategory] = useState("") + const [selectedSort, setSelectedSort] = useState("newest") + // Get current user ID from Supabase session useEffect(() => { async function getCurrentUser() { if (isAuthenticated) { - const { data } = await supabase.auth.getSession(); - const userId = data.session?.user?.id; - console.log('Current user ID:', userId); - setCurrentUserId(userId || null); + const { data } = await supabase.auth.getSession() + const userId = data.session?.user?.id + console.log("Current user ID:", userId) + setCurrentUserId(userId || null) } else { - setCurrentUserId(null); + setCurrentUserId(null) } } - - getCurrentUser(); - }, [isAuthenticated]); - + + getCurrentUser() + }, [isAuthenticated]) + // Use React Query hooks - const { - data: foods = [], + const { + data: foods = [], isLoading: isLoadingFoods, - refetch: refetchFoods - } = useFoods(selectedCategory, searchQuery, selectedSort); - - const foodIds = foods.map(food => food.id); - - const { - data: foodStats = {}, - isLoading: isLoadingStats - } = useFoodStats(foodIds); - - const creatorIds = foods - .filter(food => food.created_by) - .map(food => food.created_by as string); - - const { - data: foodCreators = {}, - isLoading: isLoadingCreators - } = useFoodCreators(creatorIds); - - const { - data: userInteractions = {}, - isLoading: isLoadingInteractions - } = useUserInteractions(foodIds, currentUserId); - - const likeMutation = useLikeMutation(); - const saveMutation = useSaveMutation(); - + refetch: refetchFoods, + } = useFoods(selectedCategory, searchQuery, selectedSort) + + const foodIds = foods.map((food) => food.id) + + const { data: foodStats = {}, isLoading: isLoadingStats } = useFoodStats(foodIds) + + const creatorIds = foods.filter((food) => food.created_by).map((food) => food.created_by as string) + + const { data: foodCreators = {}, isLoading: isLoadingCreators } = useFoodCreators(creatorIds) + + const { data: userInteractions = {}, isLoading: isLoadingInteractions } = useUserInteractions(foodIds, currentUserId) + + const likeMutation = useLikeMutation() + const saveMutation = useSaveMutation() + // Refetch data when the screen comes into focus useFocusEffect( React.useCallback(() => { - refetchFoods(); - }, [refetchFoods]) - ); - + refetchFoods() + }, [refetchFoods]), + ) + const handleSearch = (text: string) => { - setSearchQuery(text); - }; - + setSearchQuery(text) + } + const navigateToPostDetail = (food: { id: string }) => { - router.push(`/post-detail/${food.id}`); - }; - + router.push(`/post-detail/${food.id}`) + } + const handleLike = async (food: { id: string }) => { if (!isAuthenticated || !currentUserId) { - Alert.alert('Authentication Required', 'Please log in to like posts.'); - return; + Alert.alert("Authentication Required", "Please log in to like posts.") + return } - + try { - const isLiked = userInteractions[food.id]?.liked || false; - + const isLiked = userInteractions[food.id]?.liked || false + likeMutation.mutate({ foodId: food.id, userId: currentUserId, - isLiked - }); + isLiked, + }) } catch (error) { - console.error('Error toggling like:', error); - Alert.alert('Error', 'Failed to update like. Please try again.'); + console.error("Error toggling like:", error) + Alert.alert("Error", "Failed to update like. Please try again.") } - }; - + } + const handleSave = async (food: { id: string }) => { if (!isAuthenticated || !currentUserId) { - Alert.alert('Authentication Required', 'Please log in to save posts.'); - return; + Alert.alert("Authentication Required", "Please log in to save posts.") + return } - + try { - const isSaved = userInteractions[food.id]?.saved || false; - + const isSaved = userInteractions[food.id]?.saved || false + saveMutation.mutate({ foodId: food.id, userId: currentUserId, - isSaved - }); + isSaved, + }) } catch (error) { - console.error('Error toggling save:', error); - Alert.alert('Error', 'Failed to update save. Please try again.'); + console.error("Error toggling save:", error) + Alert.alert("Error", "Failed to update save. Please try again.") } - }; - + } + const renderFoodItem = ({ item }: { item: any }) => { // 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 } + // Get creator profile - const creator = item.created_by ? foodCreators[item.created_by] : null; - + const creator = item.created_by ? foodCreators[item.created_by] : null + // Get user interactions - const interactions = userInteractions[item.id] || { liked: false, saved: false }; - + const interactions = userInteractions[item.id] || { liked: false, saved: false } + return ( - navigateToPostDetail(item)} > @@ -157,90 +150,72 @@ export default function ForumScreen() { {creator?.avatar_url ? ( - + ) : ( - {creator?.username?.charAt(0).toUpperCase() || '?'} + {creator?.username?.charAt(0).toUpperCase() || "?"} )} - {creator?.username || creator?.full_name || 'Unknown Chef'} + {creator?.username || creator?.full_name || "Unknown Chef"} - - 4.2 - - - + {/* Food image */} - - + {/* Food title and description */} {item.name} {item.description} - + {/* Interaction buttons */} - { - e.stopPropagation(); - handleLike(item); + e.stopPropagation() + handleLike(item) }} > - + {stats.likes} - - navigateToPostDetail(item)} - > + + navigateToPostDetail(item)}> {stats.comments} - + { - e.stopPropagation(); - handleSave(item); + e.stopPropagation() + handleSave(item) }} > - + - ); - }; - - const isLoading = isLoadingFoods || isLoadingStats || isLoadingCreators || isLoadingInteractions; - + ) + } + + const isLoading = isLoadingFoods || isLoadingStats || isLoadingCreators || isLoadingInteractions + return ( {/* Search Bar */} @@ -255,25 +230,8 @@ export default function ForumScreen() { /> - - {/* Categories */} - - item.id} - renderItem={({ item }) => ( - setSelectedCategory(item.id === selectedCategory ? '' : item.id)} - > - {item.name} - - )} - /> - - + + {/* Sort Options */} item.id} renderItem={({ item }) => ( - setSelectedSort(item.id)} > - {item.name} - + + {item.name} + + )} /> - + {/* Food Posts */} {isLoading ? ( @@ -308,5 +270,5 @@ export default function ForumScreen() { /> )} - ); -} \ No newline at end of file + ) +} diff --git a/hooks/use-foods.ts b/hooks/use-foods.ts index 2b36b2a..d1cd916 100644 --- a/hooks/use-foods.ts +++ b/hooks/use-foods.ts @@ -47,6 +47,21 @@ export function useFoods(category?: string, search?: string, sort?: string) { 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)); + } else if (sort === 'like_desc') { + // First, we need to get likes count for each food + const likesPromises = sortedData.map(async (food) => { + const { count } = await getLikesCount(food.id); + return { foodId: food.id, likes: count || 0 }; + }); + + const likesData = await Promise.all(likesPromises); + const likesMap = likesData.reduce((acc, item) => { + acc[item.foodId] = item.likes; + return acc; + }, {} as Record); + + // Sort by likes count (high to low) + sortedData.sort((a, b) => (likesMap[b.id] || 0) - (likesMap[a.id] || 0)); } return sortedData.map(food => ({ @@ -284,4 +299,4 @@ export function useSaveMutation() { queryClient.invalidateQueries({ queryKey: [queryKeys.userInteractions] }); }, }); -} \ No newline at end of file +} From e5b6a23cf1c7d50d13eeef511dff893ef94081d1 Mon Sep 17 00:00:00 2001 From: Tantikon Phasanphaengsi Date: Sun, 11 May 2025 04:26:19 +0700 Subject: [PATCH 2/3] fix: fix keyboard error --- app/post-detail/[id].tsx | 896 +++++++++++++++++++++++------------ components/comment-input.tsx | 55 +++ package-lock.json | 12 + package.json | 1 + 4 files changed, 658 insertions(+), 306 deletions(-) create mode 100644 components/comment-input.tsx diff --git a/app/post-detail/[id].tsx b/app/post-detail/[id].tsx index 6a3dc80..f2f233c 100644 --- a/app/post-detail/[id].tsx +++ b/app/post-detail/[id].tsx @@ -1,339 +1,510 @@ -import React, { useState, useEffect } from 'react'; -import { View, Text, Image, TouchableOpacity, ScrollView, SafeAreaView, ActivityIndicator, TextInput, KeyboardAvoidingView, Platform, Alert } from 'react-native'; -import { Feather, FontAwesome } from '@expo/vector-icons'; -import { useLocalSearchParams, router } from 'expo-router'; -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 { - createLike, - deleteLike, - createSave, - deleteSave, - getComments, +"use client" + +import { useState, useEffect, useRef } from "react" +import { + View, + Text, + Image, + TouchableOpacity, + ScrollView, + ActivityIndicator, + FlatList, + Alert, + TextInput, + KeyboardAvoidingView, + Platform, + Keyboard, +} from "react-native" +import { Feather, MaterialCommunityIcons, Ionicons } from "@expo/vector-icons" +import { useLocalSearchParams, router } from "expo-router" +import { useAuth } from "../../context/auth-context" +import { supabase } from "../../services/supabase" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { + getComments, createComment, getLikesCount, getSavesCount, getCommentsCount, checkUserLiked, - checkUserSaved -} from '../../services/data/forum'; -import { getProfile } from '../../services/data/profile'; -import { Food, Ingredient, Nutrient, FoodComment, Profile } from '../../types/index'; -import { queryKeys, useLikeMutation, useSaveMutation } from '../../hooks/use-foods'; + checkUserSaved, +} from "../../services/data/forum" +import { getProfile } from "../../services/data/profile" +import { queryKeys, useLikeMutation, useSaveMutation } from "../../hooks/use-foods" export default function PostDetailScreen() { - const { id } = useLocalSearchParams(); - const foodId = typeof id === 'string' ? id : ''; - const queryClient = useQueryClient(); - - console.log('Post detail screen - Food ID:', foodId); - - const { isAuthenticated } = useAuth(); - const [currentUserId, setCurrentUserId] = useState(null); - const [commentText, setCommentText] = useState(''); - const [submittingComment, setSubmittingComment] = useState(false); - const [showReviews, setShowReviews] = useState(true); - + const params = useLocalSearchParams() + const foodId = typeof params.id === "string" ? params.id : "" + const queryClient = useQueryClient() + const scrollViewRef = useRef(null) + + console.log("Post detail screen - Food ID:", foodId) + + const { isAuthenticated } = useAuth() + const [currentUserId, setCurrentUserId] = useState(null) + const [commentText, setCommentText] = useState("") + const [submittingComment, setSubmittingComment] = useState(false) + const [showReviews, setShowReviews] = useState(true) + const [keyboardVisible, setKeyboardVisible] = useState(false) + + // Listen for keyboard events + useEffect(() => { + const keyboardDidShowListener = Keyboard.addListener("keyboardDidShow", () => { + setKeyboardVisible(true) + }) + + const keyboardDidHideListener = Keyboard.addListener("keyboardDidHide", () => { + setKeyboardVisible(false) + }) + + return () => { + keyboardDidShowListener.remove() + keyboardDidHideListener.remove() + } + }, []) + + // Recipe info cards data + const recipeInfoCards = [ + { + id: "cooking_time", + title: "Cooking Time", + icon: ( + + + + ), + value: (food: any) => food.time_to_cook_minutes, + unit: (food: any) => (food.time_to_cook_minutes === 1 ? "minute" : "minutes"), + gradient: ["#fff8e1", "#fffde7"], + valueColor: "#bb0718", + }, + { + id: "skill_level", + title: "Skill Level", + icon: ( + + + + ), + value: (food: any) => food.skill_level, + unit: () => "", + gradient: ["#e8f5e9", "#f1f8e9"], + valueColor: "", + customContent: (food: any) => ( + + + {food.skill_level} + + {renderSkillLevelDots(food.skill_level)} + + ), + }, + { + id: "ingredients", + title: "Ingredients", + icon: ( + + + + ), + value: (food: any) => food.ingredient_count, + unit: (food: any) => (food.ingredient_count === 1 ? "item" : "items"), + gradient: ["#e3f2fd", "#e8f5e9"], + valueColor: "#2196F3", + }, + { + id: "calories", + title: "Calories", + icon: ( + + + + ), + value: (food: any) => food.calories, + unit: () => "kcal", + gradient: ["#ffebee", "#fff8e1"], + valueColor: "#F44336", + }, + ] + // Get current user ID from Supabase session useEffect(() => { async function getCurrentUser() { if (isAuthenticated) { - const { data } = await supabase.auth.getSession(); - const userId = data.session?.user?.id; - console.log('Current user ID:', userId); - setCurrentUserId(userId || null); + const { data } = await supabase.auth.getSession() + const userId = data.session?.user?.id + console.log("Current user ID:", userId) + setCurrentUserId(userId || null) } else { - setCurrentUserId(null); + setCurrentUserId(null) } } - - getCurrentUser(); - }, [isAuthenticated]); - + + getCurrentUser() + }, [isAuthenticated]) + // Fetch food details - const { + const { data: food, isLoading: isLoadingFood, - error: foodError + error: foodError, } = useQuery({ queryKey: queryKeys.foodDetails(foodId), queryFn: async () => { - const { data, error } = await supabase - .from('foods') - .select('*') - .eq('id', foodId) - .single(); - - if (error) throw error; - + const { data, error } = await supabase.from("foods").select("*").eq("id", foodId).single() + + if (error) throw error + return { ...data, - description: data.description || '', + description: data.description || "", ingredient_count: data.ingredient_count ?? 0, calories: data.calories ?? 0, - image_url: data.image_url || '', - }; + time_to_cook_minutes: data.time_to_cook_minutes ?? 0, + skill_level: data.skill_level || "Easy", + image_url: data.image_url || "", + } }, enabled: !!foodId, - }); - + }) + // Fetch food creator - const { - data: foodCreator, - isLoading: isLoadingCreator - } = useQuery({ - queryKey: ['food-creator', food?.created_by], + 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; + 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 { + const { data: stats = { likes: 0, saves: 0, comments: 0 }, isLoading: isLoadingStats, - refetch: refetchStats + refetch: refetchStats, } = useQuery({ - queryKey: ['food-stats', foodId], + queryKey: ["food-stats", foodId], queryFn: async () => { const [likesRes, savesRes, commentsRes] = await Promise.all([ getLikesCount(foodId), getSavesCount(foodId), - getCommentsCount(foodId) - ]); - + getCommentsCount(foodId), + ]) + return { likes: likesRes.count || 0, saves: savesRes.count || 0, - comments: commentsRes.count || 0 - }; + comments: commentsRes.count || 0, + } }, enabled: !!foodId, - }); - + }) + // Fetch user interactions - const { + const { data: interactions = { liked: false, saved: false }, isLoading: isLoadingInteractions, - refetch: refetchInteractions + refetch: refetchInteractions, } = useQuery({ - queryKey: ['user-interactions', foodId, currentUserId], + queryKey: ["user-interactions", foodId, currentUserId], queryFn: async () => { - if (!currentUserId) return { liked: false, saved: false }; - + if (!currentUserId) return { liked: false, saved: false } + const [likedRes, savedRes] = await Promise.all([ checkUserLiked(foodId, currentUserId), - checkUserSaved(foodId, currentUserId) - ]); - + checkUserSaved(foodId, currentUserId), + ]) + return { liked: !!likedRes.data, - saved: !!savedRes.data - }; + saved: !!savedRes.data, + } }, enabled: !!foodId && !!currentUserId, - }); - + }) + // Fetch comments - const { + const { data: comments = [], isLoading: isLoadingComments, - refetch: refetchComments + refetch: refetchComments, } = useQuery({ queryKey: queryKeys.foodComments(foodId), queryFn: async () => { - const { data, error } = await getComments(foodId); - - if (error) throw error; - - return data || []; + const { data, error } = await getComments(foodId) + + if (error) throw error + + return data || [] }, enabled: !!foodId, - }); - + }) + // Set up mutations - const likeMutation = useLikeMutation(); - const saveMutation = useSaveMutation(); - + 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); + 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(''); + queryClient.invalidateQueries({ queryKey: queryKeys.foodComments(foodId) }) + queryClient.invalidateQueries({ queryKey: ["food-stats", foodId] }) + setCommentText("") + Keyboard.dismiss() }, - }); - + }) + // Set up real-time subscription for comments useEffect(() => { - if (!foodId) return; - - console.log(`Setting up real-time subscription for comments on food_id: ${foodId}`); - + if (!foodId) return + + console.log(`Setting up real-time subscription for comments on food_id: ${foodId}`) + const subscription = supabase .channel(`food_comments:${foodId}`) - .on('postgres_changes', { - event: '*', - schema: 'public', - table: 'food_comments', - filter: `food_id=eq.${foodId}` - }, () => { - console.log('Comment change detected, refreshing comments'); - refetchComments(); - refetchStats(); - }) - .subscribe(); - + .on( + "postgres_changes", + { + event: "*", + schema: "public", + table: "food_comments", + filter: `food_id=eq.${foodId}`, + }, + () => { + console.log("Comment change detected, refreshing comments") + refetchComments() + refetchStats() + }, + ) + .subscribe() + return () => { - supabase.removeChannel(subscription); - }; - }, [foodId, refetchComments, refetchStats]); - + supabase.removeChannel(subscription) + } + }, [foodId, refetchComments, refetchStats]) + // Set up real-time subscription for likes and saves useEffect(() => { - if (!foodId) return; - + if (!foodId) return + const likesSubscription = supabase .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(); - + .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() + const savesSubscription = supabase .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(); - + .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() + return () => { - supabase.removeChannel(likesSubscription); - supabase.removeChannel(savesSubscription); - }; - }, [foodId, refetchStats, refetchInteractions]); - + supabase.removeChannel(likesSubscription) + supabase.removeChannel(savesSubscription) + } + }, [foodId, refetchStats, refetchInteractions]) + const handleLike = async () => { if (!isAuthenticated || !currentUserId || !food) { - Alert.alert('Authentication Required', 'Please log in to like posts.'); - return; + Alert.alert("Authentication Required", "Please log in to like posts.") + return } - + try { likeMutation.mutate({ foodId, userId: currentUserId, - isLiked: interactions.liked - }); + isLiked: interactions.liked, + }) } catch (error) { - console.error('Error toggling like:', error); - Alert.alert('Error', 'Failed to update like. Please try again.'); + console.error("Error toggling like:", error) + Alert.alert("Error", "Failed to update like. Please try again.") } - }; - + } + const handleSave = async () => { if (!isAuthenticated || !currentUserId || !food) { - Alert.alert('Authentication Required', 'Please log in to save posts.'); - return; + Alert.alert("Authentication Required", "Please log in to save posts.") + return } - + try { saveMutation.mutate({ foodId, userId: currentUserId, - isSaved: interactions.saved - }); + isSaved: interactions.saved, + }) } catch (error) { - console.error('Error toggling save:', error); - Alert.alert('Error', 'Failed to update save. Please try again.'); + console.error("Error toggling save:", error) + Alert.alert("Error", "Failed to update save. Please try again.") } - }; - + } + const handleSubmitComment = async () => { if (!isAuthenticated || !currentUserId || !foodId || !commentText.trim()) { if (!isAuthenticated || !currentUserId) { - Alert.alert('Authentication Required', 'Please log in to comment.'); + Alert.alert("Authentication Required", "Please log in to comment.") } - return; + return } - - setSubmittingComment(true); + + setSubmittingComment(true) try { await commentMutation.mutateAsync({ foodId, userId: currentUserId, - content: commentText.trim() - }); + content: commentText.trim(), + }) } catch (error) { - console.error('Error submitting comment:', error); - Alert.alert('Error', 'Failed to submit comment. Please try again.'); + console.error("Error submitting comment:", error) + Alert.alert("Error", "Failed to submit comment. Please try again.") } finally { - setSubmittingComment(false); + setSubmittingComment(false) } - }; - - const isLoading = isLoadingFood || isLoadingCreator || isLoadingStats || isLoadingInteractions || isLoadingComments; - + } + + // Helper function to get skill level color + const getSkillLevelColor = (level: string) => { + switch (level) { + case "Easy": + return "#4CAF50" // Green + case "Medium": + return "#FFC107" // Amber + case "Hard": + return "#F44336" // Red + default: + return "#4CAF50" // Default to green + } + } + + // Helper function to get skill level dots + const renderSkillLevelDots = (level: string) => { + const totalDots = 3 + let activeDots = 1 + + if (level === "Medium") activeDots = 2 + if (level === "Hard") activeDots = 3 + + return ( + + {[...Array(totalDots)].map((_, i) => ( + + ))} + + ) + } + + // Render recipe info card + const renderRecipeInfoCard = ({ item }: { item: any }) => { + if (!food) return null + + return ( + + + {item.icon} + {item.title} + + {item.customContent ? ( + item.customContent(food) + ) : ( + + {item.value(food)} + {item.unit(food)} + + )} + + ) + } + + const isLoading = isLoadingFood || isLoadingCreator || isLoadingStats || isLoadingInteractions || isLoadingComments + if (isLoading) { return ( - - + + - - ); + + ) } - + if (foodError || !food) { return ( - - - Post not found - + + Post not found + router.back()} > - Go Back + Go Back - - ); + + ) } - + return ( - - - - {/* Header */} - + + {/* Fixed Header */} + router.back()} @@ -347,137 +518,250 @@ export default function PostDetailScreen() { - - {/* User info and rating */} - - - - {foodCreator?.avatar_url ? ( - - ) : ( - - - {foodCreator?.username?.charAt(0).toUpperCase() || food.created_by?.charAt(0).toUpperCase() || '?'} - - - )} - - - {foodCreator?.username || foodCreator?.full_name || 'Chef'} - - - - 4.2 - + + {/* Scrollable Content */} + + + {/* User info */} + + + {foodCreator?.avatar_url ? ( + + ) : ( + + + {foodCreator?.username?.charAt(0).toUpperCase() || food.created_by?.charAt(0).toUpperCase() || "?"} + + + )} + + {foodCreator?.username || foodCreator?.full_name || "Chef"} + - + {/* Food image */} - - + - + {/* Food title and description */} - - {food.name} - {food.description} - - {new Date(food.created_at).toLocaleDateString()} - {new Date(food.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + + {food.name} + {food.description} + + {new Date(food.created_at).toLocaleDateString()} -{" "} + {new Date(food.created_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} - - {/* Interaction buttons */} - - + + Recipe Details + + item.id} + contentContainerStyle={{ paddingLeft: 16, paddingRight: 8 }} + /> + + + {/* Interaction buttons */} + + - - {stats.likes} + > + + {stats.likes} - - - - {stats.comments} - - - - + + + Save - + {/* Reviews section */} - setShowReviews(!showReviews)} > - Review - + + Reviews + + {stats.comments} + + + - + {showReviews && ( - + {comments.length > 0 ? ( comments.map((comment) => ( - - - + + + {/* Profile picture */} + {comment.user?.avatar_url ? ( - + ) : ( - - - {comment.user?.username?.charAt(0).toUpperCase() || comment.user_id?.charAt(0).toUpperCase() || '?'} + + + {comment.user?.username?.charAt(0).toUpperCase() || + comment.user_id?.charAt(0).toUpperCase() || + "?"} )} - - - {comment.user?.username || comment.user?.full_name || 'User'} - - + + {/* Comment bubble with username inside */} + + + {/* Username inside bubble */} + + {comment.user?.username || comment.user?.full_name || "User"} + + + {/* Comment content */} + {comment.content} + + + {/* Date below bubble */} + {new Date(comment.created_at).toLocaleDateString()} - {comment.content} + + {/* Separator */} + )) ) : ( - No reviews yet. Be the first to comment! + + + No reviews yet. + Be the first to comment! + )} )} - - {/* Bottom spacing */} - - - {/* Comment input */} - - + + {/* Comment input - Positioned above keyboard */} + + - @@ -485,12 +769,12 @@ export default function PostDetailScreen() { {!isAuthenticated && ( - + Please log in to comment )} - - - ); -} \ No newline at end of file + + + ) +} diff --git a/components/comment-input.tsx b/components/comment-input.tsx new file mode 100644 index 0000000..7d3af81 --- /dev/null +++ b/components/comment-input.tsx @@ -0,0 +1,55 @@ +"use client" + +import { useState } from "react" +import { View, TextInput, TouchableOpacity, Text, Alert } from "react-native" +import { Feather } from "@expo/vector-icons" + +interface CommentInputProps { + isAuthenticated: boolean + onSubmit: (text: string) => Promise + isSubmitting: boolean +} + +export default function CommentInput({ isAuthenticated, onSubmit, isSubmitting }: CommentInputProps) { + const [commentText, setCommentText] = useState("") + + const handleSubmit = async () => { + if (!isAuthenticated || !commentText.trim()) { + if (!isAuthenticated) { + Alert.alert("Authentication Required", "Please log in to comment.") + } + return + } + + try { + await onSubmit(commentText.trim()) + setCommentText("") + } catch (error) { + console.error("Error submitting comment:", error) + } + } + + return ( + + + + + + + + {!isAuthenticated && Please log in to comment} + + ) +} diff --git a/package-lock.json b/package-lock.json index 8a6bc5c..cc4dc57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "expo-haptics": "~14.1.4", "expo-image": "~2.1.7", "expo-image-picker": "~16.1.4", + "expo-linear-gradient": "^14.1.4", "expo-linking": "~7.1.4", "expo-router": "~5.0.6", "expo-secure-store": "~14.2.3", @@ -6538,6 +6539,17 @@ "react": "*" } }, + "node_modules/expo-linear-gradient": { + "version": "14.1.4", + "resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-14.1.4.tgz", + "integrity": "sha512-bImj2qqIjnl+VHYGnIwan9LxmGvb8e4hFqHpxsPzUiK7Ady7uERrXPhJcyTKTxRf4RL2sQRDpoOKzBYNdQDmuw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-linking": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-7.1.4.tgz", diff --git a/package.json b/package.json index dee44a8..7563029 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "expo-haptics": "~14.1.4", "expo-image": "~2.1.7", "expo-image-picker": "~16.1.4", + "expo-linear-gradient": "^14.1.4", "expo-linking": "~7.1.4", "expo-router": "~5.0.6", "expo-secure-store": "~14.2.3", From 0cece7d075bf088cb16b68a31c2938205b7fabdc Mon Sep 17 00:00:00 2001 From: Tantikon Phasanphaengsi Date: Sun, 11 May 2025 11:32:22 +0700 Subject: [PATCH 3/3] fix: rearrange home and forum --- app/(tabs)/home.tsx | 334 +++++++++++++++++++++------------------ app/(tabs)/profile.tsx | 57 ++++--- app/post-detail/[id].tsx | 5 +- 3 files changed, 206 insertions(+), 190 deletions(-) diff --git a/app/(tabs)/home.tsx b/app/(tabs)/home.tsx index e9d9502..1ac4897 100644 --- a/app/(tabs)/home.tsx +++ b/app/(tabs)/home.tsx @@ -1,14 +1,17 @@ -import { IconSymbol } from "@/components/ui/IconSymbol"; -import { getFoods, insertGenAIResult } from "@/services/data/foods"; -import { uploadImageToSupabase } from "@/services/data/imageUpload"; -import { callGenAIonImage } from "@/services/gemini"; -import { supabase } from "@/services/supabase"; -import { Feather, FontAwesome, Ionicons } from "@expo/vector-icons"; -import { useQuery } from "@tanstack/react-query"; -import * as FileSystem from "expo-file-system"; -import * as ImagePicker from "expo-image-picker"; -import { router } from "expo-router"; -import React, { useMemo, useState } from "react"; +"use client" + +import { IconSymbol } from "@/components/ui/IconSymbol" +import { getFoods, insertGenAIResult } from "@/services/data/foods" +import { uploadImageToSupabase } from "@/services/data/imageUpload" +import { getProfile } from "@/services/data/profile" +import { callGenAIonImage } from "@/services/gemini" +import { supabase } from "@/services/supabase" +import { Feather, FontAwesome, Ionicons } from "@expo/vector-icons" +import { useQuery } from "@tanstack/react-query" +import * as FileSystem from "expo-file-system" +import * as ImagePicker from "expo-image-picker" +import { router } from "expo-router" +import { useMemo, useState, useEffect } from "react" import { Alert, Image, @@ -19,71 +22,104 @@ import { TextInput, TouchableOpacity, View, -} from "react-native"; + ActivityIndicator, +} from "react-native" const useFoodsQuery = () => { return useQuery({ queryKey: ["highlight-foods"], queryFn: async () => { - const { data, error } = await getFoods(undefined, true, undefined, 4); - if (error) throw error; - return data || []; + const { data, error } = await getFoods(undefined, true, undefined, 4) + if (error) throw error + return data || [] }, staleTime: 1000 * 60 * 5, - }); -}; + }) +} -const runImagePipeline = async ( - imageBase64: string, - imageType: string, - userId: string -) => { - const imageUri = await uploadImageToSupabase(imageBase64, imageType, userId); - const genAIResult = await callGenAIonImage(imageUri); - if (genAIResult.error) throw genAIResult.error; - const { data: genAIResultData } = genAIResult; - if (!genAIResultData) throw new Error("GenAI result is null"); - await insertGenAIResult(genAIResultData, userId, imageUri); -}; +const useUserProfile = () => { + const [userId, setUserId] = useState(null) + const [isLoadingUserId, setIsLoadingUserId] = useState(true) -const processImage = async ( - asset: ImagePicker.ImagePickerAsset, - userId: string -) => { + // Get current user ID + useEffect(() => { + const fetchUserId = async () => { + try { + const { data, error } = await supabase.auth.getUser() + if (error) throw error + setUserId(data?.user?.id || null) + } catch (error) { + console.error("Error fetching user:", error) + } finally { + setIsLoadingUserId(false) + } + } + + fetchUserId() + }, []) + + // Fetch user profile data + const { + data: profileData, + isLoading: isLoadingProfile, + error: profileError, + } = useQuery({ + queryKey: ["profile", userId], + queryFn: async () => { + if (!userId) throw new Error("No user id") + return getProfile(userId) + }, + enabled: !!userId, + staleTime: 1000 * 60 * 5, // 5 minutes + }) + + return { + userId, + profileData: profileData?.data, + isLoading: isLoadingUserId || isLoadingProfile, + error: profileError, + } +} + +const runImagePipeline = async (imageBase64: string, imageType: string, userId: string) => { + const imageUri = await uploadImageToSupabase(imageBase64, imageType, userId) + const genAIResult = await callGenAIonImage(imageUri) + if (genAIResult.error) throw genAIResult.error + const { data: genAIResultData } = genAIResult + if (!genAIResultData) throw new Error("GenAI result is null") + await insertGenAIResult(genAIResultData, userId, imageUri) +} + +const processImage = async (asset: ImagePicker.ImagePickerAsset, userId: string) => { const base64 = await FileSystem.readAsStringAsync(asset.uri, { encoding: "base64", - }); - const imageType = asset.mimeType || "image/jpeg"; - await runImagePipeline(base64, imageType, userId); -}; + }) + const imageType = asset.mimeType || "image/jpeg" + await runImagePipeline(base64, imageType, userId) +} const navigateToFoodDetail = (foodId: string) => { - router.push({ pathname: "/recipe-detail", params: { id: foodId } }); -}; + router.push({ pathname: "/recipe-detail", params: { id: foodId } }) +} const handleImageSelection = async ( - pickerFn: - | typeof ImagePicker.launchCameraAsync - | typeof ImagePicker.launchImageLibraryAsync + pickerFn: typeof ImagePicker.launchCameraAsync | typeof ImagePicker.launchImageLibraryAsync, ) => { const result = await pickerFn({ - mediaTypes: ["images"], + mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsEditing: true, aspect: [1, 1], quality: 1, - }); + }) if (!result.canceled) { try { - const { data, error } = await supabase.auth.getUser(); - if (error || !data?.user?.id) throw new Error("Cannot get user id"); - const userId = data.user.id; - await processImage(result.assets[0], userId); + const { data, error } = await supabase.auth.getUser() + if (error || !data?.user?.id) throw new Error("Cannot get user id") + const userId = data.user.id + await processImage(result.assets[0], userId) } catch (err) { - Alert.alert( - "Image Processing Failed", - (err as Error).message || "Unknown error" - ); + Alert.alert("Image Processing Failed", (err as Error).message || "Unknown error") } router.push({ pathname: "/recipe-detail", @@ -91,28 +127,38 @@ const handleImageSelection = async ( title: "My New Recipe", image: result.assets[0].uri, }, - }); + }) } -}; +} export default function HomeScreen() { - const [searchQuery, setSearchQuery] = useState(""); - const { data: foodsData = [], isLoading, error } = useFoodsQuery(); + const [searchQuery, setSearchQuery] = useState("") + const { data: foodsData = [], isLoading: isLoadingFoods, error: foodsError } = useFoodsQuery() + const { profileData, isLoading: isLoadingProfile, userId } = useUserProfile() const filteredFoods = useMemo(() => { return searchQuery - ? foodsData.filter((food) => - food.name.toLowerCase().includes(searchQuery.toLowerCase()) - ) - : foodsData; - }, [foodsData, searchQuery]); + ? foodsData.filter((food) => food.name.toLowerCase().includes(searchQuery.toLowerCase())) + : foodsData + }, [foodsData, searchQuery]) + + // Get username or fallback to a default greeting + const username = profileData?.username || profileData?.full_name || "Chef" + const greeting = `Hi! ${username}` return ( - - Hi! Mr. Chef + + {isLoadingProfile ? ( + + Hi! + + + ) : ( + {greeting} + )} @@ -123,132 +169,105 @@ export default function HomeScreen() { showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 100 }} > - - - Show your dishes - - + {/* Main content container with consistent padding */} + + {/* "Show your dishes" section */} + + + Show your dishes + + - - - - + + + + + - - { - const { status } = - await ImagePicker.requestCameraPermissionsAsync(); - if (status !== "granted") { - Alert.alert( - "Permission needed", - "Please grant camera permissions." - ); - return; - } - await handleImageSelection(ImagePicker.launchCameraAsync); - }} - > - - - From Camera - - Straight from Camera - - - - { - const { status } = - await ImagePicker.requestMediaLibraryPermissionsAsync(); - if (status !== "granted") { - Alert.alert( - "Permission needed", - "Please grant gallery permissions." - ); - return; - } - await handleImageSelection(ImagePicker.launchImageLibraryAsync); - }} - > - - - From Gallery - - Straight from Gallery - - - + {/* Upload feature section */} + + + { + const { status } = await ImagePicker.requestCameraPermissionsAsync() + if (status !== "granted") { + Alert.alert("Permission needed", "Please grant camera permissions.") + return + } + await handleImageSelection(ImagePicker.launchCameraAsync) + }} + > + + + From Camera + Straight from Camera + + + { + const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync() + if (status !== "granted") { + Alert.alert("Permission needed", "Please grant gallery permissions.") + return + } + await handleImageSelection(ImagePicker.launchImageLibraryAsync) + }} + > + + + From Gallery + Straight from Gallery + + + - + {/* Highlights section */} + - Highlights + Highlights - {isLoading ? ( - - Loading highlights... - - ) : error ? ( - - Failed to load highlights - + {isLoadingFoods ? ( + Loading highlights... + ) : foodsError ? ( + Failed to load highlights ) : filteredFoods.length === 0 ? ( - - No highlights available - + No highlights available ) : ( {filteredFoods.map((food, idx) => ( navigateToFoodDetail(food.id)} > {food.image_url ? ( - + ) : ( - + No Image )} - - + + {food.name} - + {food.description || "No description"} - {food.time_to_cook_minutes - ? `${food.time_to_cook_minutes} min` - : "-"} + {food.time_to_cook_minutes ? `${food.time_to_cook_minutes} min` : "-"} @@ -259,9 +278,10 @@ export default function HomeScreen() { )} + {/* Extra space at bottom */} - ); + ) } diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx index 5332c71..9c3be35 100644 --- a/app/(tabs)/profile.tsx +++ b/app/(tabs)/profile.tsx @@ -6,7 +6,7 @@ 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 { useIsFocused } from "@react-navigation/native" import { useQuery, useQueryClient } from "@tanstack/react-query" import * as ImagePicker from "expo-image-picker" import { useEffect, useState } from "react" @@ -23,6 +23,7 @@ import { } from "react-native" import { SafeAreaView } from "react-native-safe-area-context" import uuid from "react-native-uuid" +import { router } from "expo-router" // Define the Food type based on your database structure type Food = { @@ -44,7 +45,6 @@ export default function ProfileScreen() { const { isAuthenticated } = useAuth() const isFocused = useIsFocused() const queryClient = useQueryClient() - const navigation = useNavigation() const { data: userData, @@ -125,10 +125,9 @@ export default function ProfileScreen() { staleTime: 1000 * 60, // 1 minute }) - // Navigate to post detail + // Navigate to post detail using Expo Router instead of navigation API const handleFoodPress = (foodId: number) => { - // @ts-ignore - Navigation typing might be different in your app - navigation.navigate("post-detail", { id: foodId }) + router.push(`/post-detail/${foodId}`) } // Refetch data when tab changes @@ -237,7 +236,7 @@ export default function ProfileScreen() { if (isUserLoading) { return ( - + ) @@ -245,8 +244,8 @@ export default function ProfileScreen() { if (userError) { return ( - - {userError.message || "Failed to load user data."} + + {userError.message || "Failed to load user data."} ) } @@ -268,12 +267,12 @@ 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) @@ -281,49 +280,49 @@ export default function ProfileScreen() { setModalVisible(true) }} > - Edit + Edit {/* Edit Modal */} setModalVisible(false)}> - - - Edit Profile + + + Edit Profile Change Photo Username - {editError && {editError}} + {editError && {editError}} setModalVisible(false)} disabled={editLoading} > - Cancel + Cancel {editLoading ? ( ) : ( - Save + Save )} @@ -347,23 +346,23 @@ export default function ProfileScreen() { {/* Tab Content */} {isActiveLoading ? ( - + ) : activeError ? ( - - {activeError.message || "Failed to load data"} + + {activeError.message || "Failed to load data"} ) : !activeData?.data?.length ? ( - - No items found + + No items found ) : ( {activeData.data.map((item: Food) => ( handleFoodPress(item.id)} activeOpacity={0.7} > @@ -371,7 +370,7 @@ export default function ProfileScreen() { source={item.image_url ? { uri: item.image_url } : require("@/assets/images/placeholder-food.jpg")} className="w-full h-[120px] rounded-lg" /> - + {item.name} diff --git a/app/post-detail/[id].tsx b/app/post-detail/[id].tsx index f2f233c..152c5b2 100644 --- a/app/post-detail/[id].tsx +++ b/app/post-detail/[id].tsx @@ -504,7 +504,7 @@ export default function PostDetailScreen() { return ( {/* Fixed Header */} - + router.back()} @@ -716,9 +716,6 @@ export default function PostDetailScreen() { - - {/* Separator */} - )) ) : (