Merge pull request #3 from Sosokker/forum

Forum
This commit is contained in:
Tantikon P. 2025-05-11 04:27:15 +07:00 committed by GitHub
commit a6fc985f9b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 809 additions and 480 deletions

View File

@ -1,150 +1,143 @@
import React, { useState, useEffect } from 'react'; "use client"
import { View, Text, Image, TextInput, TouchableOpacity, FlatList, SafeAreaView, ActivityIndicator, Alert } from 'react-native';
import { Feather, FontAwesome } from '@expo/vector-icons'; import React, { useState, useEffect } from "react"
import { router, useFocusEffect } from 'expo-router'; import {
import { useAuth } from '../../context/auth-context'; View,
import { supabase } from '../../services/supabase'; 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 { import {
useFoods, useFoods,
useFoodStats, useFoodStats,
useFoodCreators, useFoodCreators,
useUserInteractions, useUserInteractions,
useLikeMutation, useLikeMutation,
useSaveMutation useSaveMutation,
} from '../../hooks/use-foods'; } from "../../hooks/use-foods"
// Categories for filtering
const categories = [
{ id: 'main', name: 'Main dish' },
{ id: 'dessert', name: 'Dessert' },
{ id: 'appetizer', name: 'Appetite' },
];
// Sort options // Sort options
const sortOptions = [ const sortOptions = [
{ id: 'rating', name: 'Rating', icon: 'star' }, { id: "newest", name: "Newest", icon: "calendar" },
{ id: 'newest', name: 'Newest', icon: 'calendar' }, { id: "like_desc", name: "Most Liked", icon: "heart" },
{ id: 'best', name: 'Best', icon: 'fire' }, ]
];
export default function ForumScreen() { 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 [selectedCategory, setSelectedCategory] = useState(''); const [selectedCategory, setSelectedCategory] = useState("")
const [selectedSort, setSelectedSort] = useState('rating'); const [selectedSort, setSelectedSort] = useState("newest")
// Get current user ID from Supabase session // Get current user ID from Supabase session
useEffect(() => { useEffect(() => {
async function getCurrentUser() { async function getCurrentUser() {
if (isAuthenticated) { if (isAuthenticated) {
const { data } = await supabase.auth.getSession(); const { data } = await supabase.auth.getSession()
const userId = data.session?.user?.id; const userId = data.session?.user?.id
console.log('Current user ID:', userId); console.log("Current user ID:", userId)
setCurrentUserId(userId || null); setCurrentUserId(userId || null)
} else { } else {
setCurrentUserId(null); setCurrentUserId(null)
} }
} }
getCurrentUser(); getCurrentUser()
}, [isAuthenticated]); }, [isAuthenticated])
// Use React Query hooks // Use React Query hooks
const { const {
data: foods = [], data: foods = [],
isLoading: isLoadingFoods, isLoading: isLoadingFoods,
refetch: refetchFoods refetch: refetchFoods,
} = useFoods(selectedCategory, searchQuery, selectedSort); } = useFoods(selectedCategory, searchQuery, selectedSort)
const foodIds = foods.map(food => food.id); const foodIds = foods.map((food) => food.id)
const { const { data: foodStats = {}, isLoading: isLoadingStats } = useFoodStats(foodIds)
data: foodStats = {},
isLoading: isLoadingStats
} = useFoodStats(foodIds);
const creatorIds = foods const creatorIds = foods.filter((food) => food.created_by).map((food) => food.created_by as string)
.filter(food => food.created_by)
.map(food => food.created_by as string);
const { const { data: foodCreators = {}, isLoading: isLoadingCreators } = useFoodCreators(creatorIds)
data: foodCreators = {},
isLoading: isLoadingCreators
} = useFoodCreators(creatorIds);
const { const { data: userInteractions = {}, isLoading: isLoadingInteractions } = useUserInteractions(foodIds, currentUserId)
data: userInteractions = {},
isLoading: isLoadingInteractions
} = useUserInteractions(foodIds, currentUserId);
const likeMutation = useLikeMutation(); const likeMutation = useLikeMutation()
const saveMutation = useSaveMutation(); const saveMutation = useSaveMutation()
// Refetch data when the screen comes into focus // Refetch data when the screen comes into focus
useFocusEffect( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {
refetchFoods(); refetchFoods()
}, [refetchFoods]) }, [refetchFoods]),
); )
const handleSearch = (text: string) => { const handleSearch = (text: string) => {
setSearchQuery(text); setSearchQuery(text)
}; }
const navigateToPostDetail = (food: { id: string }) => { const navigateToPostDetail = (food: { id: string }) => {
router.push(`/post-detail/${food.id}`); router.push(`/post-detail/${food.id}`)
}; }
const handleLike = async (food: { id: string }) => { 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
} }
try { try {
const isLiked = userInteractions[food.id]?.liked || false; const isLiked = userInteractions[food.id]?.liked || false
likeMutation.mutate({ likeMutation.mutate({
foodId: food.id, foodId: food.id,
userId: currentUserId, userId: currentUserId,
isLiked isLiked,
}); })
} 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: { id: string }) => { 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
} }
try { try {
const isSaved = userInteractions[food.id]?.saved || false; const isSaved = userInteractions[food.id]?.saved || false
saveMutation.mutate({ saveMutation.mutate({
foodId: food.id, foodId: food.id,
userId: currentUserId, userId: currentUserId,
isSaved isSaved,
}); })
} 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: any }) => { 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 }
// Get creator profile // 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 // Get user interactions
const interactions = userInteractions[item.id] || { liked: false, saved: false }; const interactions = userInteractions[item.id] || { liked: false, saved: false }
return ( return (
<TouchableOpacity <TouchableOpacity
@ -157,32 +150,25 @@ export default function ForumScreen() {
<View className="flex-row items-center"> <View className="flex-row items-center">
<View className="w-12 h-12 bg-gray-200 rounded-full overflow-hidden"> <View className="w-12 h-12 bg-gray-200 rounded-full overflow-hidden">
{creator?.avatar_url ? ( {creator?.avatar_url ? (
<Image <Image source={{ uri: creator.avatar_url }} className="w-full h-full" />
source={{ uri: creator.avatar_url }}
className="w-full h-full"
/>
) : ( ) : (
<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">
{creator?.username?.charAt(0).toUpperCase() || '?'} {creator?.username?.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">
{creator?.username || creator?.full_name || 'Unknown Chef'} {creator?.username || creator?.full_name || "Unknown Chef"}
</Text> </Text>
</View> </View>
<View className="flex-row items-center">
<Text className="text-lg font-bold mr-1">4.2</Text>
<FontAwesome name="star" size={20} color="#ffd60a" />
</View>
</View> </View>
{/* Food image */} {/* Food image */}
<View className="rounded-lg overflow-hidden mb-4"> <View className="rounded-lg overflow-hidden mb-4">
<Image <Image
source={{ uri: item.image_url || "/placeholder.svg?height=300&width=500&query=food dish" }} source={{ uri: item.image_url }}
className="w-full h-48" className="w-full h-48"
resizeMode="cover" resizeMode="cover"
/> />
@ -200,22 +186,15 @@ export default function ForumScreen() {
<TouchableOpacity <TouchableOpacity
className="flex-row items-center mr-6" className="flex-row items-center mr-6"
onPress={(e) => { onPress={(e) => {
e.stopPropagation(); e.stopPropagation()
handleLike(item); handleLike(item)
}} }}
> >
<Feather <Feather name="heart" size={22} color={interactions.liked ? "#E91E63" : "#333"} />
name="heart"
size={22}
color={interactions.liked ? "#E91E63" : "#333"}
/>
<Text className="ml-2 text-lg">{stats.likes}</Text> <Text className="ml-2 text-lg">{stats.likes}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity className="flex-row items-center mr-6" onPress={() => navigateToPostDetail(item)}>
className="flex-row items-center mr-6"
onPress={() => navigateToPostDetail(item)}
>
<Feather name="message-circle" size={22} color="#333" /> <Feather name="message-circle" size={22} color="#333" />
<Text className="ml-2 text-lg">{stats.comments}</Text> <Text className="ml-2 text-lg">{stats.comments}</Text>
</TouchableOpacity> </TouchableOpacity>
@ -223,23 +202,19 @@ export default function ForumScreen() {
<TouchableOpacity <TouchableOpacity
onPress={(e) => { onPress={(e) => {
e.stopPropagation(); e.stopPropagation()
handleSave(item); handleSave(item)
}} }}
> >
<Feather <Feather name="bookmark" size={22} color={interactions.saved ? "#ffd60a" : "#333"} />
name="bookmark"
size={22}
color={interactions.saved ? "#ffd60a" : "#333"}
/>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
); )
}; }
const isLoading = isLoadingFoods || isLoadingStats || isLoadingCreators || isLoadingInteractions; const isLoading = isLoadingFoods || isLoadingStats || isLoadingCreators || isLoadingInteractions
return ( return (
<SafeAreaView className="flex-1 bg-white"> <SafeAreaView className="flex-1 bg-white">
@ -256,23 +231,6 @@ export default function ForumScreen() {
</View> </View>
</View> </View>
{/* Categories */}
<View className="px-4 py-4">
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
data={categories}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<TouchableOpacity
className={`mr-3 px-6 py-4 rounded-lg ${selectedCategory === item.id ? 'bg-[#ffd60a]' : 'bg-[#ffd60a]'}`}
onPress={() => setSelectedCategory(item.id === selectedCategory ? '' : item.id)}
>
<Text className="text-lg font-medium">{item.name}</Text>
</TouchableOpacity>
)}
/>
</View>
{/* Sort Options */} {/* Sort Options */}
<View className="px-4 pb-4"> <View className="px-4 pb-4">
@ -283,11 +241,15 @@ export default function ForumScreen() {
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
renderItem={({ item }) => ( renderItem={({ item }) => (
<TouchableOpacity <TouchableOpacity
className={`mr-3 px-6 py-3 rounded-lg flex-row items-center ${selectedSort === item.id ? 'bg-[#bb0718]' : 'bg-[#bb0718]'}`} className={`mr-3 px-6 py-3 rounded-lg flex-row items-center ${selectedSort === item.id ? "bg-[#bb0718]" : "bg-gray-200"}`}
onPress={() => setSelectedSort(item.id)} onPress={() => setSelectedSort(item.id)}
> >
<Text className="text-lg font-medium text-[#ffd60a] mr-2">{item.name}</Text> <Text
<Feather name={item.icon as any} size={18} color="#ffd60a" /> className={`text-lg font-medium ${selectedSort === item.id ? "text-[#ffd60a]" : "text-gray-800"} mr-2`}
>
{item.name}
</Text>
<Feather name={item.icon as any} size={18} color={selectedSort === item.id ? "#ffd60a" : "#333"} />
</TouchableOpacity> </TouchableOpacity>
)} )}
/> />
@ -308,5 +270,5 @@ export default function ForumScreen() {
/> />
)} )}
</SafeAreaView> </SafeAreaView>
); )
} }

File diff suppressed because it is too large Load Diff

View File

@ -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<void>
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 (
<View className="px-4 py-3 border-t border-gray-200 bg-white">
<View className="flex-row items-center">
<TextInput
className="flex-1 bg-gray-100 rounded-full px-4 py-3 mr-2"
placeholder="Add a comment..."
value={commentText}
onChangeText={setCommentText}
editable={!isSubmitting}
/>
<TouchableOpacity
className={`p-3 rounded-full ${
commentText.trim() && isAuthenticated ? "bg-gradient-to-r from-[#ffd60a] to-[#bb0718]" : "bg-gray-300"
}`}
onPress={handleSubmit}
disabled={isSubmitting || !commentText.trim() || !isAuthenticated}
>
<Feather name="send" size={20} color={commentText.trim() && isAuthenticated ? "white" : "#666"} />
</TouchableOpacity>
</View>
{!isAuthenticated && <Text className="text-center text-sm text-red-500 mt-1">Please log in to comment</Text>}
</View>
)
}

View File

@ -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()); sortedData.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
} else if (sort === 'best') { } else if (sort === 'best') {
sortedData.sort((a, b) => (b.ingredient_count ?? 0) - (a.ingredient_count ?? 0)); 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<string, number>);
// Sort by likes count (high to low)
sortedData.sort((a, b) => (likesMap[b.id] || 0) - (likesMap[a.id] || 0));
} }
return sortedData.map(food => ({ return sortedData.map(food => ({

12
package-lock.json generated
View File

@ -29,6 +29,7 @@
"expo-image": "~2.1.7", "expo-image": "~2.1.7",
"expo-image-manipulator": "~13.1.6", "expo-image-manipulator": "~13.1.6",
"expo-image-picker": "~16.1.4", "expo-image-picker": "~16.1.4",
"expo-linear-gradient": "^14.1.4",
"expo-linking": "~7.1.4", "expo-linking": "~7.1.4",
"expo-router": "~5.0.6", "expo-router": "~5.0.6",
"expo-secure-store": "~14.2.3", "expo-secure-store": "~14.2.3",
@ -6620,6 +6621,17 @@
"react": "*" "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": { "node_modules/expo-linking": {
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-7.1.4.tgz", "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-7.1.4.tgz",

View File

@ -32,6 +32,7 @@
"expo-image": "~2.1.7", "expo-image": "~2.1.7",
"expo-image-manipulator": "~13.1.6", "expo-image-manipulator": "~13.1.6",
"expo-image-picker": "~16.1.4", "expo-image-picker": "~16.1.4",
"expo-linear-gradient": "^14.1.4",
"expo-linking": "~7.1.4", "expo-linking": "~7.1.4",
"expo-router": "~5.0.6", "expo-router": "~5.0.6",
"expo-secure-store": "~14.2.3", "expo-secure-store": "~14.2.3",