mirror of
https://github.com/Sosokker/chefhai.git
synced 2025-12-18 21:44:09 +01:00
feat: add avatar image change
This commit is contained in:
parent
f1e22594b9
commit
53c5b7b049
@ -1,27 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useAuth } from "@/context/auth-context";
|
||||||
|
import { getFoods } from "@/services/data/foods";
|
||||||
|
import { getProfile, updateProfile } from "@/services/data/profile";
|
||||||
|
import { supabase } from "@/services/supabase";
|
||||||
|
import { useIsFocused } from "@react-navigation/native";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import * as ImagePicker from "expo-image-picker";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Image,
|
Image,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Text,
|
Text,
|
||||||
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import uuid from "react-native-uuid";
|
||||||
import { useAuth } from "@/context/auth-context";
|
|
||||||
import { getFoods } from "@/services/data/foods";
|
|
||||||
import { getProfile } from "@/services/data/profile";
|
|
||||||
import { supabase } from "@/services/supabase";
|
|
||||||
import { useIsFocused } from "@react-navigation/native";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
export default function ProfileScreen() {
|
export default function ProfileScreen() {
|
||||||
const [activeTab, setActiveTab] = useState("My Recipes");
|
const [activeTab, setActiveTab] = useState("My Recipes");
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
const isFocused = useIsFocused();
|
const isFocused = useIsFocused();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: userData,
|
data: userData,
|
||||||
@ -35,7 +40,7 @@ export default function ProfileScreen() {
|
|||||||
return data?.user;
|
return data?.user;
|
||||||
},
|
},
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
subscribed: isFocused,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
const userId = userData?.id;
|
const userId = userData?.id;
|
||||||
|
|
||||||
@ -50,6 +55,7 @@ export default function ProfileScreen() {
|
|||||||
return getProfile(userId);
|
return getProfile(userId);
|
||||||
},
|
},
|
||||||
enabled: !!userId,
|
enabled: !!userId,
|
||||||
|
staleTime: 0,
|
||||||
subscribed: isFocused,
|
subscribed: isFocused,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -64,9 +70,80 @@ export default function ProfileScreen() {
|
|||||||
return getFoods(userId);
|
return getFoods(userId);
|
||||||
},
|
},
|
||||||
enabled: !!userId && activeTab === "My Recipes",
|
enabled: !!userId && activeTab === "My Recipes",
|
||||||
subscribed: isFocused && activeTab === "My Recipes",
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [editUsername, setEditUsername] = useState("");
|
||||||
|
const [editImage, setEditImage] = useState<string | null>(null);
|
||||||
|
const [editLoading, setEditLoading] = useState(false);
|
||||||
|
const [editError, setEditError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const pickImage = async () => {
|
||||||
|
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
|
if (status !== "granted") {
|
||||||
|
setEditError("Permission to access media library is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ["images"],
|
||||||
|
quality: 0.7,
|
||||||
|
allowsEditing: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.canceled) {
|
||||||
|
setEditImage(result.assets[0].uri);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadImageToSupabase = async (uri: string): Promise<string> => {
|
||||||
|
const fileName = `${userId}/${uuid.v4()}.jpg`;
|
||||||
|
const response = await fetch(uri);
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
const { error: uploadError } = await supabase.storage
|
||||||
|
.from("avatars")
|
||||||
|
.upload(fileName, blob, {
|
||||||
|
contentType: "image/jpeg",
|
||||||
|
upsert: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (uploadError) throw uploadError;
|
||||||
|
|
||||||
|
const { data } = supabase.storage.from("avatars").getPublicUrl(fileName);
|
||||||
|
return data.publicUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveProfile = async () => {
|
||||||
|
setEditLoading(true);
|
||||||
|
setEditError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!editUsername.trim()) throw new Error("Username cannot be empty");
|
||||||
|
|
||||||
|
let avatarUrl = profileData?.data?.avatar_url ?? null;
|
||||||
|
|
||||||
|
if (editImage && editImage !== avatarUrl) {
|
||||||
|
avatarUrl = await uploadImageToSupabase(editImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: updateError } = await updateProfile(
|
||||||
|
userId!,
|
||||||
|
editUsername.trim(),
|
||||||
|
avatarUrl
|
||||||
|
);
|
||||||
|
if (updateError) throw updateError;
|
||||||
|
|
||||||
|
setModalVisible(false);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["profile", userId] });
|
||||||
|
} catch (err: any) {
|
||||||
|
setEditError(err.message || "Failed to update profile");
|
||||||
|
} finally {
|
||||||
|
setEditLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (isUserLoading) {
|
if (isUserLoading) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 justify-center items-center bg-white">
|
<SafeAreaView className="flex-1 justify-center items-center bg-white">
|
||||||
@ -88,21 +165,21 @@ export default function ProfileScreen() {
|
|||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-white" edges={["top"]}>
|
<SafeAreaView className="flex-1 bg-white" edges={["top"]}>
|
||||||
<ScrollView className="flex-1">
|
<ScrollView className="flex-1">
|
||||||
{/* Profile Header */}
|
|
||||||
<View className="items-center py-6">
|
<View className="items-center py-6">
|
||||||
<View className="w-[100px] h-[100px] rounded-full border border-gray-300 justify-center items-center mb-3">
|
<View className="w-[100px] h-[100px] rounded-full border border-gray-300 justify-center items-center mb-3 overflow-hidden">
|
||||||
<View className="w-[96px] h-[96px] rounded-full bg-gray-100 justify-center items-center">
|
<Image
|
||||||
<Text className="text-5xl">👨🍳</Text>
|
source={
|
||||||
</View>
|
profileData?.data?.avatar_url
|
||||||
|
? { uri: profileData.data.avatar_url }
|
||||||
|
: require("@/assets/images/placeholder-food.jpg")
|
||||||
|
}
|
||||||
|
className="w-[96px] h-[96px] rounded-full"
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<ActivityIndicator
|
<ActivityIndicator size="small" color="#bb0718" />
|
||||||
size="small"
|
|
||||||
color="#bb0718"
|
|
||||||
style={{ marginBottom: 12 }}
|
|
||||||
/>
|
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<Text className="text-xl font-bold mb-3 text-red-600">
|
<Text className="text-red-600 font-bold mb-3">
|
||||||
{error.message || error.toString()}
|
{error.message || error.toString()}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
@ -110,9 +187,79 @@ export default function ProfileScreen() {
|
|||||||
{profileData?.data?.username ?? "-"}
|
{profileData?.data?.username ?? "-"}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<TouchableOpacity className="bg-red-600 py-2 px-10 rounded-lg">
|
<TouchableOpacity
|
||||||
|
className="bg-red-600 py-2 px-10 rounded-lg"
|
||||||
|
onPress={() => {
|
||||||
|
setEditUsername(profileData?.data?.username ?? "");
|
||||||
|
setEditImage(profileData?.data?.avatar_url ?? null);
|
||||||
|
setEditError(null);
|
||||||
|
setModalVisible(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Text className="text-white font-bold">Edit</Text>
|
<Text className="text-white font-bold">Edit</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={modalVisible}
|
||||||
|
animationType="slide"
|
||||||
|
transparent
|
||||||
|
onRequestClose={() => setModalVisible(false)}
|
||||||
|
>
|
||||||
|
<View className="flex-1 justify-center items-center bg-black bg-opacity-40">
|
||||||
|
<View className="bg-white rounded-xl p-6 w-11/12 max-w-md shadow-lg">
|
||||||
|
<Text className="text-lg font-bold mb-4 text-center">
|
||||||
|
Edit Profile
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Pressable className="items-center mb-4" onPress={pickImage}>
|
||||||
|
<Image
|
||||||
|
source={
|
||||||
|
editImage
|
||||||
|
? { uri: editImage }
|
||||||
|
: require("@/assets/images/placeholder-food.jpg")
|
||||||
|
}
|
||||||
|
className="w-24 h-24 rounded-full mb-2 bg-gray-200"
|
||||||
|
/>
|
||||||
|
<Text className="text-blue-600 underline">Change Photo</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Text className="mb-1 font-medium">Username</Text>
|
||||||
|
<TextInput
|
||||||
|
className="border border-gray-300 rounded px-3 py-2 mb-4"
|
||||||
|
value={editUsername}
|
||||||
|
onChangeText={setEditUsername}
|
||||||
|
placeholder="Enter new username"
|
||||||
|
/>
|
||||||
|
{editError && (
|
||||||
|
<Text className="text-red-600 mb-2 text-center">
|
||||||
|
{editError}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className="flex-row justify-between mt-2">
|
||||||
|
<TouchableOpacity
|
||||||
|
className="bg-gray-300 py-2 px-6 rounded-lg"
|
||||||
|
onPress={() => setModalVisible(false)}
|
||||||
|
disabled={editLoading}
|
||||||
|
>
|
||||||
|
<Text className="text-gray-700 font-bold">Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="bg-red-600 py-2 px-6 rounded-lg"
|
||||||
|
onPress={handleSaveProfile}
|
||||||
|
disabled={editLoading}
|
||||||
|
>
|
||||||
|
{editLoading ? (
|
||||||
|
<ActivityIndicator size="small" color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text className="text-white font-bold">Save</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
{/* Tab Navigation */}
|
||||||
@ -132,7 +279,7 @@ export default function ProfileScreen() {
|
|||||||
|
|
||||||
<View className="h-px bg-[#EEEEEE] mx-4" />
|
<View className="h-px bg-[#EEEEEE] mx-4" />
|
||||||
|
|
||||||
{/* Food Grid / Tab Content */}
|
{/* Recipes */}
|
||||||
{activeTab === "My Recipes" && (
|
{activeTab === "My Recipes" && (
|
||||||
<View className="flex-row flex-wrap p-2">
|
<View className="flex-row flex-wrap p-2">
|
||||||
{isFoodsLoading ? (
|
{isFoodsLoading ? (
|
||||||
@ -146,7 +293,7 @@ export default function ProfileScreen() {
|
|||||||
{foodsError.message || foodsError.toString()}
|
{foodsError.message || foodsError.toString()}
|
||||||
</Text>
|
</Text>
|
||||||
) : foodsData?.data?.length ? (
|
) : foodsData?.data?.length ? (
|
||||||
foodsData.data.map((item, index) => (
|
foodsData.data.map((item) => (
|
||||||
<View key={item.id} className="w-1/2 p-2 relative">
|
<View key={item.id} className="w-1/2 p-2 relative">
|
||||||
<Image
|
<Image
|
||||||
source={
|
source={
|
||||||
@ -170,16 +317,6 @@ export default function ProfileScreen() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{activeTab === "Likes" && (
|
|
||||||
<Text className="text-gray-400 font-bold p-4">
|
|
||||||
Liked recipes will appear here.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{activeTab === "Saved" && (
|
|
||||||
<Text className="text-gray-400 font-bold p-4">
|
|
||||||
Saved recipes will appear here.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|||||||
11
package-lock.json
generated
11
package-lock.json
generated
@ -39,6 +39,7 @@
|
|||||||
"react-native-reanimated": "^3.16.2",
|
"react-native-reanimated": "^3.16.2",
|
||||||
"react-native-safe-area-context": "^5.4.0",
|
"react-native-safe-area-context": "^5.4.0",
|
||||||
"react-native-screens": "~4.10.0",
|
"react-native-screens": "~4.10.0",
|
||||||
|
"react-native-uuid": "^2.0.3",
|
||||||
"react-native-web": "~0.20.0",
|
"react-native-web": "~0.20.0",
|
||||||
"react-native-webview": "13.13.5",
|
"react-native-webview": "13.13.5",
|
||||||
"tailwindcss": "^3.4.17"
|
"tailwindcss": "^3.4.17"
|
||||||
@ -10889,6 +10890,16 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-uuid": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-native-uuid/-/react-native-uuid-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-f/YfIS2f5UB+gut7t/9BKGSCYbRA9/74A5R1MDp+FLYsuS+OSWoiM/D8Jko6OJB6Jcu3v6ONuddvZKHdIGpeiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0",
|
||||||
|
"npm": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-native-web": {
|
"node_modules/react-native-web": {
|
||||||
"version": "0.20.0",
|
"version": "0.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.20.0.tgz",
|
||||||
|
|||||||
@ -42,6 +42,7 @@
|
|||||||
"react-native-reanimated": "^3.16.2",
|
"react-native-reanimated": "^3.16.2",
|
||||||
"react-native-safe-area-context": "^5.4.0",
|
"react-native-safe-area-context": "^5.4.0",
|
||||||
"react-native-screens": "~4.10.0",
|
"react-native-screens": "~4.10.0",
|
||||||
|
"react-native-uuid": "^2.0.3",
|
||||||
"react-native-web": "~0.20.0",
|
"react-native-web": "~0.20.0",
|
||||||
"react-native-webview": "13.13.5",
|
"react-native-webview": "13.13.5",
|
||||||
"tailwindcss": "^3.4.17"
|
"tailwindcss": "^3.4.17"
|
||||||
|
|||||||
@ -28,15 +28,27 @@ export async function getProfile(userId: string): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the username of a user in the `profiles` table.
|
* Updates the username (and optionally avatar_url) of a user in the `profiles` table.
|
||||||
*/
|
*/
|
||||||
export async function updateProfile(userId: string, username: string): Promise<{ data: any; error: PostgrestError | null }> {
|
export async function updateProfile(
|
||||||
const { data, error } = await supabase
|
userId: string,
|
||||||
.from('profiles')
|
username?: string | null,
|
||||||
.update({ username: username })
|
avatar_url?: string | null
|
||||||
.eq('id', userId)
|
): Promise<{ data: any; error: PostgrestError | null }> {
|
||||||
.select()
|
const updateData: Record<string, string | null> = {}
|
||||||
.single()
|
if (username !== undefined && username !== null) {
|
||||||
|
updateData.username = username
|
||||||
return { data, error }
|
}
|
||||||
}
|
if (avatar_url !== undefined && avatar_url !== null) {
|
||||||
|
updateData.avatar_url = avatar_url
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.update(updateData)
|
||||||
|
.eq('id', userId)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
return { data, error }
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user