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,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<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState('');
const [selectedSort, setSelectedSort] = useState('rating');
const { isAuthenticated } = useAuth()
const [currentUserId, setCurrentUserId] = useState<string | null>(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 (
<TouchableOpacity
<TouchableOpacity
className="mb-6 bg-white rounded-lg overflow-hidden shadow-sm"
onPress={() => navigateToPostDetail(item)}
>
@ -157,90 +150,72 @@ export default function ForumScreen() {
<View className="flex-row items-center">
<View className="w-12 h-12 bg-gray-200 rounded-full overflow-hidden">
{creator?.avatar_url ? (
<Image
source={{ uri: creator.avatar_url }}
className="w-full h-full"
/>
<Image source={{ uri: creator.avatar_url }} className="w-full h-full" />
) : (
<View className="w-full h-full bg-gray-300 items-center justify-center">
<Text className="text-base font-bold text-gray-600">
{creator?.username?.charAt(0).toUpperCase() || '?'}
{creator?.username?.charAt(0).toUpperCase() || "?"}
</Text>
</View>
)}
</View>
<Text className="ml-3 text-lg font-bold">
{creator?.username || creator?.full_name || 'Unknown Chef'}
{creator?.username || creator?.full_name || "Unknown Chef"}
</Text>
</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>
{/* Food image */}
<View className="rounded-lg overflow-hidden mb-4">
<Image
source={{ uri: item.image_url || "/placeholder.svg?height=300&width=500&query=food dish" }}
<Image
source={{ uri: item.image_url }}
className="w-full h-48"
resizeMode="cover"
/>
</View>
{/* Food title and description */}
<View>
<Text className="text-2xl font-bold mb-2">{item.name}</Text>
<Text className="text-gray-700 mb-4">{item.description}</Text>
</View>
{/* Interaction buttons */}
<View className="flex-row justify-between">
<View className="flex-row items-center">
<TouchableOpacity
<TouchableOpacity
className="flex-row items-center mr-6"
onPress={(e) => {
e.stopPropagation();
handleLike(item);
e.stopPropagation()
handleLike(item)
}}
>
<Feather
name="heart"
size={22}
color={interactions.liked ? "#E91E63" : "#333"}
/>
<Feather name="heart" size={22} color={interactions.liked ? "#E91E63" : "#333"} />
<Text className="ml-2 text-lg">{stats.likes}</Text>
</TouchableOpacity>
<TouchableOpacity
className="flex-row items-center mr-6"
onPress={() => navigateToPostDetail(item)}
>
<TouchableOpacity className="flex-row items-center mr-6" onPress={() => navigateToPostDetail(item)}>
<Feather name="message-circle" size={22} color="#333" />
<Text className="ml-2 text-lg">{stats.comments}</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
onPress={(e) => {
e.stopPropagation();
handleSave(item);
e.stopPropagation()
handleSave(item)
}}
>
<Feather
name="bookmark"
size={22}
color={interactions.saved ? "#ffd60a" : "#333"}
/>
<Feather name="bookmark" size={22} color={interactions.saved ? "#ffd60a" : "#333"} />
</TouchableOpacity>
</View>
</View>
</TouchableOpacity>
);
};
const isLoading = isLoadingFoods || isLoadingStats || isLoadingCreators || isLoadingInteractions;
)
}
const isLoading = isLoadingFoods || isLoadingStats || isLoadingCreators || isLoadingInteractions
return (
<SafeAreaView className="flex-1 bg-white">
{/* Search Bar */}
@ -255,25 +230,8 @@ export default function ForumScreen() {
/>
</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 */}
<View className="px-4 pb-4">
<FlatList
@ -282,17 +240,21 @@ export default function ForumScreen() {
data={sortOptions}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<TouchableOpacity
className={`mr-3 px-6 py-3 rounded-lg flex-row items-center ${selectedSort === item.id ? 'bg-[#bb0718]' : 'bg-[#bb0718]'}`}
<TouchableOpacity
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)}
>
<Text className="text-lg font-medium text-[#ffd60a] mr-2">{item.name}</Text>
<Feather name={item.icon as any} size={18} color="#ffd60a" />
<Text
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>
)}
/>
</View>
{/* Food Posts */}
{isLoading ? (
<View className="flex-1 items-center justify-center">
@ -308,5 +270,5 @@ export default function ForumScreen() {
/>
)}
</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());
} 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<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 => ({
@ -284,4 +299,4 @@ export function useSaveMutation() {
queryClient.invalidateQueries({ queryKey: [queryKeys.userInteractions] });
},
});
}
}

12
package-lock.json generated
View File

@ -29,6 +29,7 @@
"expo-image": "~2.1.7",
"expo-image-manipulator": "~13.1.6",
"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",
@ -6620,6 +6621,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",

View File

@ -32,6 +32,7 @@
"expo-image": "~2.1.7",
"expo-image-manipulator": "~13.1.6",
"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",