diff --git a/app/profile/[id]/profile-page-client.tsx b/app/profile/[id]/profile-page-client.tsx index 93fbdc8..e76a5f2 100644 --- a/app/profile/[id]/profile-page-client.tsx +++ b/app/profile/[id]/profile-page-client.tsx @@ -30,6 +30,7 @@ export function ProfilePageClient({ profile, recentContributions }: ProfilePageC role={profile.role} verified={profile.verified} createdAt={profile.createdAt} + image={profile.image} /> diff --git a/app/profile/page.tsx b/app/profile/page.tsx index abb3d1f..baa1c88 100644 --- a/app/profile/page.tsx +++ b/app/profile/page.tsx @@ -104,6 +104,7 @@ export default function ProfilePage() { role={profile.role} verified={profile.verified} createdAt={profile.createdAt} + image={profile.image} /> @@ -175,6 +176,8 @@ export default function ProfilePage() { email={profile.email} role={profile.role} createdAt={profile.createdAt} + image={profile.image} + userId={profile.id} /> )} diff --git a/components/profile/profile-header.tsx b/components/profile/profile-header.tsx index 02cb5a1..fec34b0 100644 --- a/components/profile/profile-header.tsx +++ b/components/profile/profile-header.tsx @@ -1,6 +1,6 @@ "use client" -import { Shield, Crown, CheckCircle, Mail } from "lucide-react" +import { Shield, Crown, CheckCircle, Mail, User } from "lucide-react" import { motion } from "motion/react" interface ProfileHeaderProps { @@ -9,6 +9,7 @@ interface ProfileHeaderProps { role: string | null verified: boolean createdAt: string + image?: string | null } const roleConfig: Record = { @@ -17,7 +18,15 @@ const roleConfig: Record
+ {image ? ( +
+ {`${name}'s +
+ ) : ( +
+ {getInitials(name)} +
+ )}

{name}

diff --git a/components/profile/profile-photo-upload.tsx b/components/profile/profile-photo-upload.tsx new file mode 100644 index 0000000..c639c7a --- /dev/null +++ b/components/profile/profile-photo-upload.tsx @@ -0,0 +1,276 @@ +"use client" + +import { useState, useRef, useEffect, useCallback } from "react" +import { motion, AnimatePresence } from "motion/react" +import { Camera, Trash2, Loader2, Upload } from "lucide-react" + +interface ProfilePhotoUploadProps { + currentImage: string | null + userName: string + userId: string +} + +const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"] +const MAX_SIZE_MB = 5 +const MAX_SIZE_BYTES = MAX_SIZE_MB * 1024 * 1024 + +function isR2Avatar(url: string): boolean { + return url.includes(".r2.dev") +} + +function getInitials(name: string): string { + return name.charAt(0).toUpperCase() +} + +export function ProfilePhotoUpload({ currentImage, userName, userId }: ProfilePhotoUploadProps) { + const [previewUrl, setPreviewUrl] = useState(currentImage) + const [uploadState, setUploadState] = useState<"idle" | "uploading" | "success" | "error">("idle") + const [errorMessage, setErrorMessage] = useState(null) + const [isDragging, setIsDragging] = useState(false) + const fileInputRef = useRef(null) + const tempUrlRef = useRef(null) + + const cleanupTempUrl = () => { + if (tempUrlRef.current) { + URL.revokeObjectURL(tempUrlRef.current) + tempUrlRef.current = null + } + } + + useEffect(() => { + return cleanupTempUrl + }, []) + + useEffect(() => { + setPreviewUrl(currentImage) + }, [currentImage]) + + const validateFile = (file: File): string | null => { + if (!ALLOWED_TYPES.includes(file.type)) { + return "Only JPEG, PNG, and WebP images are allowed." + } + if (file.size > MAX_SIZE_BYTES) { + return "File must be under 5MB." + } + return null + } + + const handleFile = async (file: File) => { + const validationError = validateFile(file) + if (validationError) { + setErrorMessage(validationError) + setUploadState("error") + return + } + + setErrorMessage(null) + setUploadState("uploading") + cleanupTempUrl() + + const objectUrl = URL.createObjectURL(file) + tempUrlRef.current = objectUrl + setPreviewUrl(objectUrl) + + const formData = new FormData() + formData.append("file", file) + + try { + const res = await fetch("/api/user/profile-photo", { + method: "POST", + body: formData, + }) + + if (!res.ok) { + const data = await res.json().catch(() => ({})) + throw new Error(data.error || "Upload failed") + } + + const data = await res.json() + if (data.imageUrl) { + setPreviewUrl(data.imageUrl) + cleanupTempUrl() + } + setUploadState("success") + } catch (err) { + setErrorMessage(err instanceof Error ? err.message : "Upload failed") + setUploadState("error") + setPreviewUrl(currentImage) + cleanupTempUrl() + } + } + + const handleInputChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) handleFile(file) + e.target.value = "" + } + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + const file = e.dataTransfer.files?.[0] + if (file) handleFile(file) + }, [currentImage]) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragging(true) + }, []) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + }, []) + + const handleDelete = async () => { + if (!previewUrl || !isR2Avatar(previewUrl)) return + + setUploadState("uploading") + try { + const res = await fetch("/api/user/profile-photo", { + method: "DELETE", + }) + + if (!res.ok) { + const data = await res.json().catch(() => ({})) + throw new Error(data.error || "Delete failed") + } + + setPreviewUrl(null) + setUploadState("idle") + setErrorMessage(null) + } catch (err) { + setErrorMessage(err instanceof Error ? err.message : "Delete failed") + setUploadState("error") + } + } + + const triggerFileInput = () => { + fileInputRef.current?.click() + } + + const showDelete = previewUrl ? isR2Avatar(previewUrl) : false + + return ( +
+

Profile Photo

+ +
+ {/* Avatar area */} +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + triggerFileInput() + } + }} + > + {previewUrl ? ( + {`${userName}'s + ) : ( +
+ {getInitials(userName)} +
+ )} + + {/* Upload overlay */} +
+ +
+ + {uploadState === "uploading" && ( +
+ +
+ )} +
+ + {/* Controls */} +
+
+ + + + {showDelete && ( + + + + )} + +
+ +

+ JPEG, PNG, or WebP. Max 5MB. +

+ + + {errorMessage && ( + + {errorMessage} + + )} + + + {uploadState === "success" && !errorMessage && ( + + Photo updated successfully. + + )} +
+
+ + {/* Hidden file input */} + + +
+ ) +} diff --git a/components/profile/settings-container.tsx b/components/profile/settings-container.tsx index b88d80e..7591ece 100644 --- a/components/profile/settings-container.tsx +++ b/components/profile/settings-container.tsx @@ -26,6 +26,8 @@ interface SettingsContainerProps { email: string role: string | null createdAt: string + image?: string | null + userId: string } export function SettingsContainer({ @@ -33,6 +35,8 @@ export function SettingsContainer({ email, role, createdAt, + image, + userId, }: SettingsContainerProps) { const [activeSubTab, setActiveSubTab] = useState("profile") const [authMethods, setAuthMethods] = useState(null) @@ -120,6 +124,8 @@ export function SettingsContainer({ email={email} role={role} createdAt={createdAt} + image={image} + userId={userId} /> )} {activeSubTab === "security" && ( diff --git a/components/profile/settings-profile-tab.tsx b/components/profile/settings-profile-tab.tsx index 70f99b5..9127957 100644 --- a/components/profile/settings-profile-tab.tsx +++ b/components/profile/settings-profile-tab.tsx @@ -5,14 +5,18 @@ import { authClient } from "@/lib/auth-client" import { Loader2, Save } from "lucide-react" import { motion } from "motion/react" +import { ProfilePhotoUpload } from "@/components/profile/profile-photo-upload" + interface SettingsProfileTabProps { name: string email: string role: string | null createdAt: string + image?: string | null + userId: string } -export function SettingsProfileTab({ name, email, role, createdAt }: SettingsProfileTabProps) { +export function SettingsProfileTab({ name, email, role, createdAt, image, userId }: SettingsProfileTabProps) { const [displayName, setDisplayName] = useState(name) const [isSaving, setIsSaving] = useState(false) const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null) @@ -49,6 +53,9 @@ export function SettingsProfileTab({ name, email, role, createdAt }: SettingsPro animate={{ opacity: 1, y: 0 }} className="space-y-6" > + {/* Profile Photo */} + + {/* Display Name */}

Display Name