From 7b491cb6881c7f7310f891924ad25902a970dfce Mon Sep 17 00:00:00 2001 From: Tantikon Phasanphaengsi Date: Tue, 13 May 2025 00:23:24 +0700 Subject: [PATCH] feat: horizontal ellipsis in post detail --- app/post-detail/[id].tsx | 753 ++++++++++++++++++--------------------- package-lock.json | 10 + package.json | 1 + services/data/foods.ts | 10 +- 4 files changed, 361 insertions(+), 413 deletions(-) diff --git a/app/post-detail/[id].tsx b/app/post-detail/[id].tsx index fbfe008..7de261f 100644 --- a/app/post-detail/[id].tsx +++ b/app/post-detail/[id].tsx @@ -1,77 +1,181 @@ -"use client"; +"use client" -import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { router, useLocalSearchParams } from "expo-router"; -import { useEffect, useRef, useState } from "react"; +import { useState, useEffect, useRef } from "react" import { - ActivityIndicator, - Alert, - FlatList, + View, + Text, Image, - Keyboard, + TouchableOpacity, + ScrollView, + ActivityIndicator, + FlatList, + Alert, + TextInput, KeyboardAvoidingView, Platform, - ScrollView, - Text, - TextInput, - TouchableOpacity, - View, -} from "react-native"; -import { useAuth } from "../../context/auth-context"; + 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 { - queryKeys, - useLikeMutation, - useSaveMutation, -} from "../../hooks/use-foods"; -import { - checkUserLiked, - checkUserSaved, - createComment, getComments, - getCommentsCount, + createComment, getLikesCount, getSavesCount, -} from "../../services/data/forum"; -import { getProfile } from "../../services/data/profile"; -import { supabase } from "../../services/supabase"; + getCommentsCount, + checkUserLiked, + checkUserSaved, +} from "../../services/data/forum" +import { getProfile } from "../../services/data/profile" +import { queryKeys, useLikeMutation, useSaveMutation } from "../../hooks/use-foods" +import { updateFoodSharing, deleteFood } from "../../services/data/foods" + +function MenuButton({ food, currentUserId }: { food: any; currentUserId: string | null }) { + const [menuVisible, setMenuVisible] = useState(false) + const queryClient = useQueryClient() + + const isOwner = currentUserId && food.created_by === currentUserId + + const updateSharingMutation = useMutation({ + mutationFn: async ({ foodId, isShared }: { foodId: string; isShared: boolean }) => { + const { error } = await updateFoodSharing(foodId, isShared) + if (error) throw error + return { success: true } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["food", food.id] }) + Alert.alert("Success", `Food is now ${!food.is_shared ? "public" : "private"}`) + setMenuVisible(false) + }, + onError: (error) => { + console.error("Error updating sharing status:", error) + Alert.alert("Error", "Failed to update sharing status") + }, + }) + + const deleteFoodMutation = useMutation({ + mutationFn: async (foodId: string) => { + const { error } = await deleteFood(foodId) + if (error) throw error + return { success: true } + }, + onSuccess: () => { + router.back() + queryClient.invalidateQueries({ queryKey: ["my-recipes", currentUserId] }) + Alert.alert("Success", "Food deleted successfully") + }, + onError: (error) => { + console.error("Error deleting food:", error) + Alert.alert("Error", "Failed to delete food") + }, + }) + + const handleToggleSharing = () => { + if (!isOwner) { + Alert.alert("Permission Denied", "You can only modify your own posts") + return + } + + updateSharingMutation.mutate({ + foodId: food.id, + isShared: !food.is_shared, + }) + } + + const handleDelete = () => { + if (!isOwner) { + Alert.alert("Permission Denied", "You can only delete your own posts") + return + } + + Alert.alert("Confirm Delete", "Are you sure you want to delete this post? This action cannot be undone.", [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => deleteFoodMutation.mutate(food.id), + }, + ]) + } + + const handleNavigateToFood = () => { + router.push(`/food/${food.id}`) + setMenuVisible(false) + } + + return ( + + setMenuVisible(!menuVisible)}> + + + + {menuVisible && ( + + + + {food.is_shared ? "Make Private" : "Make Public"} + + + + + View Recipe + + + {isOwner && ( + + + Delete + + )} + + )} + + ) +} export default function PostDetailScreen() { - const params = useLocalSearchParams(); - const foodId = typeof params.id === "string" ? params.id : ""; - const queryClient = useQueryClient(); - const scrollViewRef = useRef(null); + 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); + 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); + 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 keyboardDidShowListener = Keyboard.addListener("keyboardDidShow", () => { + setKeyboardVisible(true) + // Scroll to bottom when keyboard appears + setTimeout(() => { + scrollViewRef.current?.scrollToEnd({ animated: true }) + }, 100) + }) - const keyboardDidHideListener = Keyboard.addListener( - "keyboardDidHide", - () => { - setKeyboardVisible(false); - } - ); + const keyboardDidHideListener = Keyboard.addListener("keyboardDidHide", () => { + setKeyboardVisible(false) + }) return () => { - keyboardDidShowListener.remove(); - keyboardDidHideListener.remove(); - }; - }, []); + keyboardDidShowListener.remove() + keyboardDidHideListener.remove() + } + }, []) // Recipe info cards data const recipeInfoCards = [ @@ -79,15 +183,12 @@ export default function PostDetailScreen() { 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", + unit: (food: any) => (food.time_to_cook_minutes === 1 ? "minute" : "minutes"), gradient: ["#fff8e1", "#fffde7"], valueColor: "#bb0718", }, @@ -95,9 +196,7 @@ export default function PostDetailScreen() { id: "skill_level", title: "Skill Level", icon: ( - + ), @@ -107,13 +206,7 @@ export default function PostDetailScreen() { valueColor: "", customContent: (food: any) => ( - + {food.skill_level} {renderSkillLevelDots(food.skill_level)} @@ -124,9 +217,7 @@ export default function PostDetailScreen() { id: "ingredients", title: "Ingredients", icon: ( - + ), @@ -139,9 +230,7 @@ export default function PostDetailScreen() { id: "calories", title: "Calories", icon: ( - + ), @@ -150,23 +239,23 @@ export default function PostDetailScreen() { 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 { @@ -176,13 +265,9 @@ export default function PostDetailScreen() { } = useQuery({ queryKey: queryKeys.foodDetails(foodId), queryFn: async () => { - const { data, error } = await supabase - .from("foods") - .select("*") - .eq("id", foodId) - .single(); + const { data, error } = await supabase.from("foods").select("*").eq("id", foodId).single() - if (error) throw error; + if (error) throw error return { ...data, @@ -192,25 +277,25 @@ export default function PostDetailScreen() { 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], queryFn: async () => { - if (!food?.created_by) return null; + if (!food?.created_by) return null - const { data, error } = await getProfile(food.created_by); + const { data, error } = await getProfile(food.created_by) - if (error) throw error; + if (error) throw error - return data; + return data }, enabled: !!food?.created_by, - }); + }) // Fetch food stats const { @@ -224,16 +309,16 @@ export default function PostDetailScreen() { getLikesCount(foodId), getSavesCount(foodId), getCommentsCount(foodId), - ]); + ]) return { likes: likesRes.count || 0, saves: savesRes.count || 0, comments: commentsRes.count || 0, - }; + } }, enabled: !!foodId, - }); + }) // Fetch user interactions const { @@ -243,20 +328,20 @@ export default function PostDetailScreen() { } = useQuery({ 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), - ]); + ]) return { liked: !!likedRes.data, saved: !!savedRes.data, - }; + } }, enabled: !!foodId && !!currentUserId, - }); + }) // Fetch comments const { @@ -266,48 +351,46 @@ export default function PostDetailScreen() { } = useQuery({ queryKey: queryKeys.foodComments(foodId), queryFn: async () => { - const { data, error } = await getComments(foodId); + const { data, error } = await getComments(foodId) - if (error) throw error; + if (error) throw error - return data || []; + 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: () => { + // Invalidate the comments for this specific food + queryClient.invalidateQueries({ queryKey: queryKeys.foodComments(foodId) }) + + // Invalidate the food stats for this food + queryClient.invalidateQueries({ queryKey: ["food-stats", foodId] }) + + // Also invalidate the general food stats that might be used in the forum screen queryClient.invalidateQueries({ - queryKey: queryKeys.foodComments(foodId), - }); - queryClient.invalidateQueries({ queryKey: ["food-stats", foodId] }); - setCommentText(""); - Keyboard.dismiss(); + queryKey: ["food-stats", foodId], + exact: false, + }) + + setCommentText("") + Keyboard.dismiss() }, - }); + }) // Set up real-time subscription for comments useEffect(() => { - if (!foodId) return; + if (!foodId) return - console.log( - `Setting up real-time subscription for comments on food_id: ${foodId}` - ); + console.log(`Setting up real-time subscription for comments on food_id: ${foodId}`) const subscription = supabase .channel(`food_comments:${foodId}`) @@ -320,21 +403,21 @@ export default function PostDetailScreen() { filter: `food_id=eq.${foodId}`, }, () => { - console.log("Comment change detected, refreshing comments"); - refetchComments(); - refetchStats(); - } + console.log("Comment change detected, refreshing comments") + refetchComments() + refetchStats() + }, ) - .subscribe(); + .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}`) @@ -347,14 +430,12 @@ export default function PostDetailScreen() { filter: `food_id=eq.${foodId}`, }, () => { - console.log( - "Like change detected, refreshing stats and interactions" - ); - refetchStats(); - refetchInteractions(); - } + console.log("Like change detected, refreshing stats and interactions") + refetchStats() + refetchInteractions() + }, ) - .subscribe(); + .subscribe() const savesSubscription = supabase .channel(`food_saves:${foodId}`) @@ -367,25 +448,23 @@ export default function PostDetailScreen() { filter: `food_id=eq.${foodId}`, }, () => { - console.log( - "Save change detected, refreshing stats and interactions" - ); - refetchStats(); - refetchInteractions(); - } + console.log("Save change detected, refreshing stats and interactions") + refetchStats() + refetchInteractions() + }, ) - .subscribe(); + .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 { @@ -393,17 +472,17 @@ export default function PostDetailScreen() { foodId, userId: currentUserId, 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 { @@ -411,57 +490,57 @@ export default function PostDetailScreen() { foodId, userId: currentUserId, 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(), - }); + }) } 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) } - }; + } // Helper function to get skill level color const getSkillLevelColor = (level: string) => { switch (level) { case "Easy": - return "#4CAF50"; // Green + return "#4CAF50" // Green case "Medium": - return "#FFC107"; // Amber + return "#FFC107" // Amber case "Hard": - return "#F44336"; // Red + return "#F44336" // Red default: - return "#4CAF50"; // Default to green + return "#4CAF50" // Default to green } - }; + } // Helper function to get skill level dots const renderSkillLevelDots = (level: string) => { - const totalDots = 3; - let activeDots = 1; + const totalDots = 3 + let activeDots = 1 - if (level === "Medium") activeDots = 2; - if (level === "Hard") activeDots = 3; + if (level === "Medium") activeDots = 2 + if (level === "Hard") activeDots = 3 return ( @@ -479,12 +558,12 @@ export default function PostDetailScreen() { /> ))} - ); - }; + ) + } // Render recipe info card const renderRecipeInfoCard = ({ item }: { item: any }) => { - if (!food) return null; + if (!food) return null return ( - + {item.icon} - - {item.title} - + {item.title} {item.customContent ? ( item.customContent(food) ) : ( - - {item.value(food)} - - - {item.unit(food)} - + {item.value(food)} + {item.unit(food)} )} - ); - }; + ) + } - const isLoading = - isLoadingFood || - isLoadingCreator || - isLoadingStats || - isLoadingInteractions || - isLoadingComments; + const isLoading = isLoadingFood || isLoadingCreator || isLoadingStats || isLoadingInteractions || isLoadingComments if (isLoading) { return ( - + - ); + ) } if (foodError || !food) { return ( - + Post not found - ); + ) } return ( - - {/* Fixed Header */} - - router.back()} - > - - - - Post - - router.push(`/food/${food.id}`)}> - - - - - {/* Scrollable Content */} - + + + {/* Fixed Header */} + + router.back()}> + + + + Post + + + + {/* User info */} - - + + {foodCreator?.avatar_url ? ( - + ) : ( - - {foodCreator?.username?.charAt(0).toUpperCase() || - food.created_by?.charAt(0).toUpperCase() || - "?"} + + {foodCreator?.username?.charAt(0).toUpperCase() || food.created_by?.charAt(0).toUpperCase() || "?"} )} @@ -671,38 +685,17 @@ export default function PostDetailScreen() { {/* Food title and description */} - - {food.name} - - - {food.description} - + {food.name} + {food.description} {new Date(food.created_at).toLocaleDateString()} -{" "} - {new Date(food.created_at).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - })} + {new Date(food.created_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} {/* Recipe Info Cards - Horizontal Scrollable */} - + Recipe Details - - {stats.likes} - + {stats.likes} - - - Save - + + Save @@ -793,18 +778,10 @@ export default function PostDetailScreen() { borderRadius: 12, }} > - - {stats.comments} - + {stats.comments} - + {showReviews && ( @@ -824,10 +801,7 @@ export default function PostDetailScreen() { }} > {comment.user?.avatar_url ? ( - + ) : ( - - {comment.user?.username - ?.charAt(0) - .toUpperCase() || + + {comment.user?.username?.charAt(0).toUpperCase() || comment.user_id?.charAt(0).toUpperCase() || "?"} @@ -857,69 +823,42 @@ export default function PostDetailScreen() { {/* Comment bubble with username inside */} - + {/* Username inside bubble */} - - {comment.user?.username || - comment.user?.full_name || - "User"} + + {comment.user?.username || comment.user?.full_name || "User"} {/* Comment content */} - - {comment.content} - + {comment.content} {/* Date below bubble */} - + {new Date(comment.created_at).toLocaleDateString()} + + {/* Separator */} + )) ) : ( - - No reviews yet. - - - Be the first to comment! - + No reviews yet. + Be the first to comment! )} )} + + {/* Extra space at the bottom to ensure content is visible above keyboard */} + - {/* Comment input - Positioned above keyboard */} + {/* Comment input */} { + // Scroll to bottom when input is focused + setTimeout(() => { + scrollViewRef.current?.scrollToEnd({ animated: true }) + }, 100) + }} /> - + {!isAuthenticated && ( - + Please log in to comment )} - - - ); + + + ) } diff --git a/package-lock.json b/package-lock.json index cfad776..7a6e6ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "react-native": "0.79.2", "react-native-gesture-handler": "~2.24.0", "react-native-reanimated": "^3.16.2", + "react-native-responsive-screen": "^1.4.2", "react-native-safe-area-context": "^5.4.0", "react-native-screens": "~4.10.0", "react-native-uuid": "^2.0.3", @@ -11072,6 +11073,15 @@ "react-native": "*" } }, + "node_modules/react-native-responsive-screen": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/react-native-responsive-screen/-/react-native-responsive-screen-1.4.2.tgz", + "integrity": "sha512-BLYz0UUpeohrib7jbz6wDmtBD5OmiuMRko4IT8kIF63taXPod/c5iZgmWnr5qOnK8hMuKiGMvsM3sC+eHX/lEQ==", + "license": "MIT", + "peerDependencies": { + "react-native": ">=0.35" + } + }, "node_modules/react-native-safe-area-context": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.4.0.tgz", diff --git a/package.json b/package.json index a1abfa1..d36a0f7 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "react-native": "0.79.2", "react-native-gesture-handler": "~2.24.0", "react-native-reanimated": "^3.16.2", + "react-native-responsive-screen": "^1.4.2", "react-native-safe-area-context": "^5.4.0", "react-native-screens": "~4.10.0", "react-native-uuid": "^2.0.3", diff --git a/services/data/foods.ts b/services/data/foods.ts index b7cd004..79eb521 100644 --- a/services/data/foods.ts +++ b/services/data/foods.ts @@ -226,4 +226,12 @@ export const insertGenAIResult = async ( } return { data: foodId, error: null }; -}; \ No newline at end of file +}; + +export async function updateFoodSharing(foodId: string, isShared: boolean) { + return await supabase.from("foods").update({ is_shared: isShared }).eq("id", foodId) +} + +export async function deleteFood(foodId: string) { + return await supabase.from("foods").delete().eq("id", foodId) +}