From 0288bf8eda41a370ff2e7e37bca0fcc9c8d0f0c0 Mon Sep 17 00:00:00 2001 From: songtianlun Date: Mon, 28 Jul 2025 23:14:14 +0800 Subject: [PATCH] good profile --- next.config.ts | 29 +- src/app/profile/page.tsx | 540 +++++++++++++++++++++++++++++++ src/components/layout/Header.tsx | 12 +- src/components/ui/avatar.tsx | 44 +++ src/components/ui/textarea.tsx | 23 ++ src/hooks/useAuth.ts | 1 + 6 files changed, 642 insertions(+), 7 deletions(-) create mode 100644 src/app/profile/page.tsx create mode 100644 src/components/ui/avatar.tsx create mode 100644 src/components/ui/textarea.tsx diff --git a/next.config.ts b/next.config.ts index e9ffa30..2f858d4 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,34 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'lh3.googleusercontent.com', + port: '', + pathname: '/**', + }, + { + protocol: 'https', + hostname: 'avatars.githubusercontent.com', + port: '', + pathname: '/**', + }, + { + protocol: 'https', + hostname: '*.supabase.co', + port: '', + pathname: '/**', + }, + { + protocol: 'https', + hostname: '*.r2.cloudflarestorage.com', + port: '', + pathname: '/**', + } + ], + }, }; export default nextConfig; diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx new file mode 100644 index 0000000..781f915 --- /dev/null +++ b/src/app/profile/page.tsx @@ -0,0 +1,540 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useAuth } from '@/hooks/useAuth' +import { createClient } from '@/lib/supabase' +import { Header } from '@/components/layout/Header' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Avatar } from '@/components/ui/avatar' +import { Camera, Save, Eye, EyeOff, Globe } from 'lucide-react' + +interface UserProfile { + id: string + email: string + username?: string + bio?: string + avatar_url?: string + language?: 'en' | 'zh' +} + +export default function ProfilePage() { + const { user, loading } = useAuth() + const [profile, setProfile] = useState(null) + const [isEditing, setIsEditing] = useState({ + username: false, + email: false, + bio: false, + password: false + }) + const [formData, setFormData] = useState({ + username: '', + email: '', + bio: '', + currentPassword: '', + newPassword: '', + confirmPassword: '', + language: 'en' as 'en' | 'zh' + }) + const [showPasswords, setShowPasswords] = useState({ + current: false, + new: false, + confirm: false + }) + const [saveStatus, setSaveStatus] = useState<{type: 'success' | 'error' | null, message: string}>({type: null, message: ''}) + const [isLoading, setIsLoading] = useState(false) + + const supabase = createClient() + + useEffect(() => { + if (user) { + loadProfile() + } + }, [user]) // eslint-disable-line react-hooks/exhaustive-deps + + const loadProfile = async () => { + if (!user) return + + try { + // Get user metadata and profile data + const { data: { user: userData }, error: userError } = await supabase.auth.getUser() + + if (userError) throw userError + + const profileData: UserProfile = { + id: userData?.id || '', + email: userData?.email || '', + username: userData?.user_metadata?.username || userData?.user_metadata?.full_name || '', + bio: userData?.user_metadata?.bio || '', + avatar_url: userData?.user_metadata?.avatar_url || '', + language: userData?.user_metadata?.language || 'en' + } + + setProfile(profileData) + setFormData({ + username: profileData.username || '', + email: profileData.email, + bio: profileData.bio || '', + currentPassword: '', + newPassword: '', + confirmPassword: '', + language: profileData.language || 'en' + }) + } catch (error) { + console.error('Error loading profile:', error) + setSaveStatus({type: 'error', message: 'Failed to load profile'}) + } + } + + const updateProfile = async (field: string, value: string) => { + if (!user) return + + setIsLoading(true) + setSaveStatus({type: null, message: ''}) + + try { + const updates: Record = {} + + if (field === 'email') { + const { error } = await supabase.auth.updateUser({ email: value }) + if (error) throw error + setSaveStatus({type: 'success', message: 'Check your email to confirm the change'}) + } else { + updates[field] = value + const { error } = await supabase.auth.updateUser({ + data: updates + }) + if (error) throw error + setSaveStatus({type: 'success', message: `${field} updated successfully`}) + } + + // Reload profile + await loadProfile() + + // Reset editing state + setIsEditing(prev => ({ ...prev, [field]: false })) + + } catch (error: unknown) { + setSaveStatus({type: 'error', message: (error instanceof Error ? error.message : 'Unknown error') || `Failed to update ${field}`}) + } finally { + setIsLoading(false) + } + } + + const updatePassword = async () => { + if (!formData.newPassword || formData.newPassword !== formData.confirmPassword) { + setSaveStatus({type: 'error', message: 'Passwords do not match'}) + return + } + + if (formData.newPassword.length < 6) { + setSaveStatus({type: 'error', message: 'Password must be at least 6 characters'}) + return + } + + setIsLoading(true) + setSaveStatus({type: null, message: ''}) + + try { + const { error } = await supabase.auth.updateUser({ + password: formData.newPassword + }) + + if (error) throw error + + setSaveStatus({type: 'success', message: 'Password updated successfully'}) + setFormData(prev => ({ + ...prev, + currentPassword: '', + newPassword: '', + confirmPassword: '' + })) + setIsEditing(prev => ({ ...prev, password: false })) + + } catch (error: unknown) { + setSaveStatus({type: 'error', message: (error instanceof Error ? error.message : 'Unknown error') || 'Failed to update password'}) + } finally { + setIsLoading(false) + } + } + + const uploadAvatar = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (!file || !user) return + + setIsLoading(true) + setSaveStatus({type: null, message: ''}) + + try { + // Create a unique filename for future use with actual file storage + // const fileExt = file.name.split('.').pop() + // const fileName = `${user.id}-${Date.now()}.${fileExt}` + + // For now, we'll use a placeholder upload since we need to configure storage + // In a real implementation, you would upload to Supabase Storage or Cloudflare R2 + const reader = new FileReader() + reader.onload = async (e) => { + const dataUrl = e.target?.result as string + + const { error } = await supabase.auth.updateUser({ + data: { avatar_url: dataUrl } + }) + + if (error) throw error + + await loadProfile() + setSaveStatus({type: 'success', message: 'Avatar updated successfully'}) + setIsLoading(false) + } + reader.readAsDataURL(file) + + } catch (error: unknown) { + setSaveStatus({type: 'error', message: (error instanceof Error ? error.message : 'Unknown error') || 'Failed to upload avatar'}) + setIsLoading(false) + } + } + + if (loading) { + return ( +
+
+
+ ) + } + + if (!user) { + return ( +
+
+

Access Denied

+

Please sign in to access your profile

+ +
+
+ ) + } + + return ( +
+
+ +
+
+

Profile Settings

+

Manage your account settings and preferences

+
+ + {saveStatus.type && ( +
+ {saveStatus.message} +
+ )} + +
+ {/* Avatar Section */} +
+
+

Profile Picture

+
+
+ + +
+

+ Click the camera icon to upload a new picture +

+
+
+
+ + {/* Profile Information */} +
+ {/* Username */} +
+
+

Username

+ {!isEditing.username && ( + + )} +
+ + {isEditing.username ? ( +
+ setFormData(prev => ({ ...prev, username: e.target.value }))} + placeholder="Enter username" + /> +
+ + +
+
+ ) : ( +

{profile?.username || 'No username set'}

+ )} +
+ + {/* Email */} +
+
+

Email

+ {!isEditing.email && ( + + )} +
+ + {isEditing.email ? ( +
+ setFormData(prev => ({ ...prev, email: e.target.value }))} + placeholder="Enter email" + /> +
+ + +
+
+ ) : ( +

{profile?.email}

+ )} +
+ + {/* Bio */} +
+
+

Bio

+ {!isEditing.bio && ( + + )} +
+ + {isEditing.bio ? ( +
+