From a8fa2183ee1d68ad14bbda75213addee667d2c34 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 9 Aug 2025 00:51:12 +0530 Subject: [PATCH] minor Ui fixes. --- src/components/common/ToastOverlay.tsx | 120 +++++++++ src/contexts/AccountContext.tsx | 32 ++- src/navigation/AppNavigator.tsx | 14 ++ src/screens/AccountManageScreen.tsx | 324 +++++++++++++++++++++++++ src/screens/AuthScreen.tsx | 85 +++---- src/screens/HomeScreen.tsx | 49 +++- src/screens/OnboardingScreen.tsx | 26 +- src/screens/SettingsScreen.tsx | 10 +- src/services/AccountService.ts | 26 +- 9 files changed, 607 insertions(+), 79 deletions(-) create mode 100644 src/components/common/ToastOverlay.tsx create mode 100644 src/screens/AccountManageScreen.tsx diff --git a/src/components/common/ToastOverlay.tsx b/src/components/common/ToastOverlay.tsx new file mode 100644 index 0000000..7a28dc6 --- /dev/null +++ b/src/components/common/ToastOverlay.tsx @@ -0,0 +1,120 @@ +import React, { useEffect, useMemo, useRef } from 'react'; +import { Animated, Easing, StyleSheet, Text, ViewStyle } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +export type ToastType = 'success' | 'error' | 'info'; + +type Props = { + visible: boolean; + message: string; + type?: ToastType; + duration?: number; // ms + onHide?: () => void; + bottomOffset?: number; // extra offset above safe area / tab bar + containerStyle?: ViewStyle; +}; + +const colorsByType: Record = { + success: 'rgba(46,160,67,0.95)', + error: 'rgba(229, 62, 62, 0.95)', + info: 'rgba(99, 102, 241, 0.95)', +}; + +export const ToastOverlay: React.FC = ({ + visible, + message, + type = 'info', + duration = 1800, + onHide, + bottomOffset = 90, + containerStyle, +}) => { + const insets = useSafeAreaInsets(); + const opacity = useRef(new Animated.Value(0)).current; + const translateY = useRef(new Animated.Value(12)).current; + const hideTimer = useRef(null); + + useEffect(() => { + if (visible) { + // clear any existing timer + if (hideTimer.current) { + clearTimeout(hideTimer.current); + hideTimer.current = null; + } + opacity.setValue(0); + translateY.setValue(12); + Animated.parallel([ + Animated.timing(opacity, { toValue: 1, duration: 160, easing: Easing.out(Easing.cubic), useNativeDriver: true }), + Animated.timing(translateY, { toValue: 0, duration: 160, easing: Easing.out(Easing.cubic), useNativeDriver: true }), + ]).start(() => { + hideTimer.current = setTimeout(() => { + Animated.parallel([ + Animated.timing(opacity, { toValue: 0, duration: 160, easing: Easing.in(Easing.cubic), useNativeDriver: true }), + Animated.timing(translateY, { toValue: 12, duration: 160, easing: Easing.in(Easing.cubic), useNativeDriver: true }), + ]).start(() => { + if (onHide) onHide(); + }); + }, Math.max(800, duration)); + }); + } else { + // If toggled off externally, hide instantly + Animated.parallel([ + Animated.timing(opacity, { toValue: 0, duration: 120, easing: Easing.in(Easing.cubic), useNativeDriver: true }), + Animated.timing(translateY, { toValue: 12, duration: 120, easing: Easing.in(Easing.cubic), useNativeDriver: true }), + ]).start(); + if (hideTimer.current) { + clearTimeout(hideTimer.current); + hideTimer.current = null; + } + } + + return () => { + if (hideTimer.current) { + clearTimeout(hideTimer.current); + hideTimer.current = null; + } + }; + }, [visible, duration, onHide, opacity, translateY]); + + const bg = useMemo(() => colorsByType[type], [type]); + const bottom = (insets?.bottom || 0) + bottomOffset; + + if (!visible && opacity.__getValue() === 0) { + // Avoid mounting when fully hidden to minimize layout cost + return null; + } + + return ( + + + {message} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + left: 16, + right: 16, + zIndex: 999, + }, + text: { + color: '#fff', + fontWeight: '700', + textAlign: 'center', + paddingVertical: 8, + paddingHorizontal: 12, + borderRadius: 12, + overflow: 'hidden', + fontSize: 12, + }, +}); + +export default ToastOverlay; + + diff --git a/src/contexts/AccountContext.tsx b/src/contexts/AccountContext.tsx index 9dac746..db366be 100644 --- a/src/contexts/AccountContext.tsx +++ b/src/contexts/AccountContext.tsx @@ -9,6 +9,7 @@ type AccountContextValue = { signIn: (email: string, password: string) => Promise; signUp: (email: string, password: string) => Promise; signOut: () => Promise; + updateProfile: (partial: { avatarUrl?: string; displayName?: string }) => Promise; }; const AccountContext = createContext(undefined); @@ -18,7 +19,7 @@ export const AccountProvider: React.FC<{ children: React.ReactNode }> = ({ child const [loading, setLoading] = useState(true); useEffect(() => { - // Initial session + // Initial session (load full profile) (async () => { const u = await accountService.getCurrentUser(); setUser(u); @@ -27,24 +28,22 @@ export const AccountProvider: React.FC<{ children: React.ReactNode }> = ({ child if (u) { await syncService.migrateLocalScopeToUser(); await syncService.subscribeRealtime(); - await Promise.all([ - syncService.fullPull(), - syncService.fullPush(), - ]); + // Pull first to hydrate local state, then push to avoid wiping server with empty local + await syncService.fullPull(); + await syncService.fullPush(); } })(); // Auth state listener const { data: subscription } = supabase.auth.onAuthStateChange(async (_event, session) => { - const u = session?.user ? { id: session.user.id, email: session.user.email ?? undefined } : null; - setUser(u); - if (u) { + const fullUser = session?.user ? await accountService.getCurrentUser() : null; + setUser(fullUser); + if (fullUser) { await syncService.migrateLocalScopeToUser(); await syncService.subscribeRealtime(); - await Promise.all([ - syncService.fullPull(), - syncService.fullPush(), - ]); + // Pull first to hydrate local state, then push to avoid wiping server with empty local + await syncService.fullPull(); + await syncService.fullPush(); } else { syncService.unsubscribeRealtime(); } @@ -69,6 +68,15 @@ export const AccountProvider: React.FC<{ children: React.ReactNode }> = ({ child signOut: async () => { await accountService.signOut(); setUser(null); + }, + updateProfile: async (partial) => { + const err = await accountService.updateProfile(partial); + if (!err) { + // Refresh user from server to pick updated fields + const u = await accountService.getCurrentUser(); + setUser(u); + } + return err; } }), [user, loading]); diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 6c92a1d..d8af6a5 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -40,6 +40,7 @@ import ThemeScreen from '../screens/ThemeScreen'; import ProfilesScreen from '../screens/ProfilesScreen'; import OnboardingScreen from '../screens/OnboardingScreen'; import AuthScreen from '../screens/AuthScreen'; +import AccountManageScreen from '../screens/AccountManageScreen'; import { AccountProvider, useAccount } from '../contexts/AccountContext'; import PluginsScreen from '../screens/PluginsScreen'; @@ -93,6 +94,7 @@ export type RootStackParamList = { Credits: { mediaId: string; mediaType: string }; ShowRatings: { showId: number }; Account: undefined; + AccountManage: undefined; Payment: undefined; PrivacyPolicy: undefined; About: undefined; @@ -748,6 +750,18 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta }, }} /> + { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const { user, signOut, updateProfile } = useAccount(); + const { currentTheme } = useTheme(); + + const headerOpacity = useRef(new Animated.Value(0)).current; + const headerTranslateY = useRef(new Animated.Value(8)).current; + const contentOpacity = useRef(new Animated.Value(0)).current; + const contentTranslateY = useRef(new Animated.Value(8)).current; + + useEffect(() => { + Animated.parallel([ + Animated.timing(headerOpacity, { toValue: 1, duration: 260, easing: Easing.out(Easing.cubic), useNativeDriver: true }), + Animated.timing(headerTranslateY, { toValue: 0, duration: 260, easing: Easing.out(Easing.cubic), useNativeDriver: true }), + Animated.timing(contentOpacity, { toValue: 1, duration: 320, delay: 80, easing: Easing.out(Easing.cubic), useNativeDriver: true }), + Animated.timing(contentTranslateY, { toValue: 0, duration: 320, delay: 80, easing: Easing.out(Easing.cubic), useNativeDriver: true }), + ]).start(); + }, [headerOpacity, headerTranslateY, contentOpacity, contentTranslateY]); + + const initial = useMemo(() => (user?.email?.[0]?.toUpperCase() || 'U'), [user?.email]); + const [displayName, setDisplayName] = useState(user?.displayName || ''); + const [avatarUrl, setAvatarUrl] = useState(user?.avatarUrl || ''); + const [saving, setSaving] = useState(false); + const [avatarError, setAvatarError] = useState(false); + + useEffect(() => { + // Reset image error state when URL changes + setAvatarError(false); + }, [avatarUrl]); + + const handleSave = async () => { + if (saving) return; + setSaving(true); + const err = await updateProfile({ displayName: displayName.trim() || undefined, avatarUrl: avatarUrl.trim() || undefined }); + if (err) { + Alert.alert('Error', err); + } + setSaving(false); + }; + + const handleSignOut = () => { + Alert.alert( + 'Sign out', + 'Are you sure you want to sign out?', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Sign out', + style: 'destructive', + onPress: async () => { + try { + await signOut(); + // Navigate back to root after sign out + // @ts-ignore + navigation.goBack(); + } catch (_) {} + }, + }, + ] + ); + }; + + return ( + + + + {/* Header */} + + + navigation.goBack()} style={styles.headerBack} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}> + + + Account + + + + {/* Content */} + + {/* Profile Badge */} + + {avatarUrl && !avatarError ? ( + + setAvatarError(true)} + /> + + ) : ( + + {(displayName?.[0] || initial)} + + )} + + + {/* Account details card */} + + + + + Display name + + + + + + + + + + Avatar URL + + + + + + + + + + Email + + + {user?.email || '—'} + + + + + + + + + User ID + + + {user?.id} + + + + + {/* Save and Sign out */} + + {saving ? ( + + ) : ( + <> + + Save changes + + )} + + + + + Sign out + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + paddingHorizontal: 16, + paddingBottom: 14, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + headerBack: { + padding: 4, + }, + headerTitle: { + fontSize: 18, + fontWeight: '800', + }, + content: { + flex: 1, + paddingHorizontal: 16, + paddingTop: 10, + }, + profileContainer: { + alignItems: 'center', + marginBottom: 12, + }, + avatar: { + width: 72, + height: 72, + borderRadius: 36, + alignItems: 'center', + justifyContent: 'center', + }, + avatarImage: { + width: '100%', + height: '100%', + }, + avatarText: { + color: '#fff', + fontWeight: '800', + fontSize: 24, + }, + card: { + borderRadius: 14, + borderWidth: StyleSheet.hairlineWidth, + paddingHorizontal: 14, + paddingVertical: 10, + }, + itemRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 10, + }, + itemRowCompact: { + paddingVertical: 6, + }, + input: { + flex: 1, + textAlign: 'right', + paddingVertical: 6, + marginLeft: 12, + }, + itemLeft: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + itemTitle: { + fontSize: 14, + fontWeight: '600', + }, + itemValue: { + fontSize: 14, + maxWidth: '65%', + }, + divider: { + height: StyleSheet.hairlineWidth, + backgroundColor: 'rgba(255,255,255,0.08)', + }, + signOutButton: { + marginTop: 16, + height: 48, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + }, + signOutText: { + color: '#fff', + fontWeight: '700', + }, + saveButton: { + marginTop: 12, + height: 46, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + borderWidth: StyleSheet.hairlineWidth, + }, + saveText: { + color: '#fff', + fontWeight: '700', + }, +}); + +export default AccountManageScreen; + + diff --git a/src/screens/AuthScreen.tsx b/src/screens/AuthScreen.tsx index ef51b63..3c9a067 100644 --- a/src/screens/AuthScreen.tsx +++ b/src/screens/AuthScreen.tsx @@ -1,11 +1,13 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { View, TextInput, Text, TouchableOpacity, StyleSheet, ActivityIndicator, SafeAreaView, KeyboardAvoidingView, Platform, Dimensions, Animated, Easing, Keyboard } from 'react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import { LinearGradient } from 'expo-linear-gradient'; import { MaterialIcons } from '@expo/vector-icons'; import { useTheme } from '../contexts/ThemeContext'; import { useAccount } from '../contexts/AccountContext'; import { useNavigation } from '@react-navigation/native'; import * as Haptics from 'expo-haptics'; +import ToastOverlay from '../components/common/ToastOverlay'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; const { width, height } = Dimensions.get('window'); @@ -35,11 +37,10 @@ const AuthScreen: React.FC = () => { const ctaTextTranslateY = useRef(new Animated.Value(0)).current; const modeAnim = useRef(new Animated.Value(0)).current; // 0 = signin, 1 = signup const [switchWidth, setSwitchWidth] = useState(0); - const toastOpacity = useRef(new Animated.Value(0)).current; - const toastTranslateY = useRef(new Animated.Value(16)).current; const [toast, setToast] = useState<{ visible: boolean; message: string; type: 'success' | 'error' | 'info' }>({ visible: false, message: '', type: 'info' }); const [headerHeight, setHeaderHeight] = useState(0); const headerHideAnim = useRef(new Animated.Value(0)).current; // 0 visible, 1 hidden + const [keyboardHeight, setKeyboardHeight] = useState(0); useEffect(() => { Animated.parallel([ @@ -101,7 +102,9 @@ const AuthScreen: React.FC = () => { useEffect(() => { const showEvt = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; const hideEvt = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; - const onShow = () => { + const onShow = (e: any) => { + const kh = e?.endCoordinates?.height ?? 0; + setKeyboardHeight(kh); Animated.timing(headerHideAnim, { toValue: 1, duration: 180, @@ -110,6 +113,7 @@ const AuthScreen: React.FC = () => { }).start(); }; const onHide = () => { + setKeyboardHeight(0); Animated.timing(headerHideAnim, { toValue: 0, duration: 180, @@ -117,8 +121,8 @@ const AuthScreen: React.FC = () => { useNativeDriver: true, }).start(); }; - const subShow = Keyboard.addListener(showEvt, onShow); - const subHide = Keyboard.addListener(hideEvt, onHide); + const subShow = Keyboard.addListener(showEvt, onShow as any); + const subHide = Keyboard.addListener(hideEvt, onHide as any); return () => { subShow.remove(); subHide.remove(); @@ -160,21 +164,15 @@ const AuthScreen: React.FC = () => { setLoading(false); }; + const handleSkipAuth = async () => { + try { + await AsyncStorage.setItem('showLoginHintToastOnce', 'true'); + } catch {} + navigation.reset({ index: 0, routes: [{ name: 'MainTabs' as never }] } as any); + }; + const showToast = (message: string, type: 'success' | 'error' | 'info' = 'info') => { setToast({ visible: true, message, type }); - toastOpacity.setValue(0); - toastTranslateY.setValue(16); - Animated.parallel([ - Animated.timing(toastOpacity, { toValue: 1, duration: 160, easing: Easing.out(Easing.cubic), useNativeDriver: true }), - Animated.timing(toastTranslateY, { toValue: 0, duration: 160, easing: Easing.out(Easing.cubic), useNativeDriver: true }), - ]).start(() => { - setTimeout(() => { - Animated.parallel([ - Animated.timing(toastOpacity, { toValue: 0, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }), - Animated.timing(toastTranslateY, { toValue: 16, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }), - ]).start(() => setToast(prev => ({ ...prev, visible: false }))); - }, 2200); - }); }; return ( @@ -443,24 +441,30 @@ const AuthScreen: React.FC = () => { + + {/* Skip sign in */} + + + Continue without an account + + - {/* Toast */} - {toast.visible && ( - - - {toast.message} - - )} + {/* Screen-level toast overlay so it is not clipped by the card */} + 0 ? keyboardHeight + 8 : 24)} + onHide={() => setToast(prev => ({ ...prev, visible: false }))} + /> ); @@ -619,23 +623,6 @@ const styles = StyleSheet.create({ left: 16, top: 8, }, - toast: { - position: 'absolute', - bottom: 24, - left: 20, - right: 20, - borderRadius: 12, - paddingHorizontal: 12, - paddingVertical: 10, - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }, - toastText: { - color: '#fff', - fontWeight: '600', - flex: 1, - }, switchModeText: { textAlign: 'center', fontSize: 14, diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 7e4bef8..4da7b4b 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -63,6 +63,8 @@ import { useTheme } from '../contexts/ThemeContext'; import type { Theme } from '../contexts/ThemeContext'; import * as ScreenOrientation from 'expo-screen-orientation'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import ToastOverlay from '../components/common/ToastOverlay'; import FirstTimeWelcome from '../components/FirstTimeWelcome'; import { imageCacheService } from '../services/imageCacheService'; @@ -122,8 +124,10 @@ const HomeScreen = () => { const [catalogsLoading, setCatalogsLoading] = useState(true); const [loadedCatalogCount, setLoadedCatalogCount] = useState(0); const [hasAddons, setHasAddons] = useState(null); + const [hintVisible, setHintVisible] = useState(false); const totalCatalogsRef = useRef(0); const [visibleCatalogCount, setVisibleCatalogCount] = useState(8); // Moderate number of visible catalogs + const insets = useSafeAreaInsets(); const { featuredContent, @@ -307,6 +311,24 @@ const HomeScreen = () => { loadCatalogsProgressively(); }, [lastUpdate, loadCatalogsProgressively]); + // One-time hint after skipping login in onboarding + useEffect(() => { + let hideTimer: any; + (async () => { + try { + const flag = await AsyncStorage.getItem('showLoginHintToastOnce'); + if (flag === 'true') { + setHintVisible(true); + await AsyncStorage.removeItem('showLoginHintToastOnce'); + hideTimer = setTimeout(() => setHintVisible(false), 2000); + } + } catch {} + })(); + return () => { + if (hideTimer) clearTimeout(hideTimer); + }; + }, []); + // Create a refresh function for catalogs const refreshCatalogs = useCallback(() => { return loadCatalogsProgressively(); @@ -677,7 +699,7 @@ const HomeScreen = () => { if (isLoading) return null; return ( - + { disableIntervalMomentum={true} scrollEventThrottle={16} /> + {/* One-time hint toast after skipping sign-in */} + setHintVisible(false)} + /> ); }, [ @@ -860,6 +891,22 @@ const styles = StyleSheet.create({ fontSize: 14, fontWeight: '600', }, + toast: { + position: 'absolute', + bottom: 24, + left: 16, + right: 16, + paddingVertical: 8, + paddingHorizontal: 10, + backgroundColor: 'rgba(99, 102, 241, 0.95)', + borderRadius: 12, + }, + toastText: { + color: '#fff', + fontWeight: '600', + textAlign: 'center', + fontSize: 12, + }, loadingContainer: { flex: 1, justifyContent: 'center', diff --git a/src/screens/OnboardingScreen.tsx b/src/screens/OnboardingScreen.tsx index e81f18a..3409bde 100644 --- a/src/screens/OnboardingScreen.tsx +++ b/src/screens/OnboardingScreen.tsx @@ -23,6 +23,7 @@ import Animated, { } from 'react-native-reanimated'; import { useTheme } from '../contexts/ThemeContext'; import { NavigationProp, useNavigation } from '@react-navigation/native'; +import { useAccount } from '../contexts/AccountContext'; import { RootStackParamList } from '../navigation/AppNavigator'; import AsyncStorage from '@react-native-async-storage/async-storage'; @@ -75,6 +76,7 @@ const onboardingData: OnboardingSlide[] = [ const OnboardingScreen = () => { const { currentTheme } = useTheme(); const navigation = useNavigation>(); + const { user } = useAccount(); const [currentIndex, setCurrentIndex] = useState(0); const flatListRef = useRef(null); const progressValue = useSharedValue(0); @@ -95,22 +97,28 @@ const OnboardingScreen = () => { }; const handleSkip = () => { - handleGetStarted(); + // Skip login: proceed to app and show a one-time hint toast + (async () => { + try { + await AsyncStorage.setItem('hasCompletedOnboarding', 'true'); + await AsyncStorage.setItem('showLoginHintToastOnce', 'true'); + } catch {} + navigation.reset({ index: 0, routes: [{ name: 'MainTabs' }] }); + })(); }; const handleGetStarted = async () => { try { await AsyncStorage.setItem('hasCompletedOnboarding', 'true'); - navigation.reset({ - index: 0, - routes: [{ name: 'MainTabs' }], - }); + // After onboarding, route to login if no user; otherwise go to app + if (!user) { + navigation.reset({ index: 0, routes: [{ name: 'Account' }] }); + } else { + navigation.reset({ index: 0, routes: [{ name: 'MainTabs' }] }); + } } catch (error) { console.error('Error saving onboarding status:', error); - navigation.reset({ - index: 0, - routes: [{ name: 'MainTabs' }], - }); + navigation.reset({ index: 0, routes: [{ name: user ? 'MainTabs' : 'Account' }] }); } }; diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 07cdda5..ab68a5c 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -300,14 +300,10 @@ const SettingsScreen: React.FC = () => { {user ? ( <> - navigation.navigate('AccountManage')} /> ) : ( diff --git a/src/services/AccountService.ts b/src/services/AccountService.ts index f29b0ad..029cea1 100644 --- a/src/services/AccountService.ts +++ b/src/services/AccountService.ts @@ -4,6 +4,8 @@ import supabase from './supabaseClient'; export type AuthUser = { id: string; email?: string; + avatarUrl?: string; + displayName?: string; }; const USER_SCOPE_KEY = '@user:current'; @@ -22,6 +24,10 @@ class AccountService { if (error) return { error: error.message }; const user = data.user ? { id: data.user.id, email: data.user.email ?? undefined } : undefined; if (user) await AsyncStorage.setItem(USER_SCOPE_KEY, user.id); + // Initialize profile row + if (user) { + await supabase.from('user_profiles').upsert({ user_id: user.id }, { onConflict: 'user_id' }); + } return { user }; } @@ -42,7 +48,25 @@ class AccountService { const { data } = await supabase.auth.getUser(); const u = data.user; if (!u) return null; - return { id: u.id, email: u.email ?? undefined }; + // Fetch profile for avatar and display name + const { data: profile } = await supabase + .from('user_profiles') + .select('avatar_url, display_name') + .eq('user_id', u.id) + .maybeSingle(); + return { id: u.id, email: u.email ?? undefined, avatarUrl: profile?.avatar_url ?? undefined, displayName: profile?.display_name ?? undefined }; + } + + async updateProfile(partial: { avatarUrl?: string; displayName?: string }): Promise { + const { data } = await supabase.auth.getUser(); + const userId = data.user?.id; + if (!userId) return 'Not authenticated'; + const { error } = await supabase.from('user_profiles').upsert({ + user_id: userId, + avatar_url: partial.avatarUrl, + display_name: partial.displayName, + }, { onConflict: 'user_id' }); + return error?.message ?? null; } async getCurrentUserIdScoped(): Promise {