feat: add profile photo upload UI component and integrate into settings
This commit is contained in:
@@ -30,6 +30,7 @@ export function ProfilePageClient({ profile, recentContributions }: ProfilePageC
|
|||||||
role={profile.role}
|
role={profile.role}
|
||||||
verified={profile.verified}
|
verified={profile.verified}
|
||||||
createdAt={profile.createdAt}
|
createdAt={profile.createdAt}
|
||||||
|
image={profile.image}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export default function ProfilePage() {
|
|||||||
role={profile.role}
|
role={profile.role}
|
||||||
verified={profile.verified}
|
verified={profile.verified}
|
||||||
createdAt={profile.createdAt}
|
createdAt={profile.createdAt}
|
||||||
|
image={profile.image}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -175,6 +176,8 @@ export default function ProfilePage() {
|
|||||||
email={profile.email}
|
email={profile.email}
|
||||||
role={profile.role}
|
role={profile.role}
|
||||||
createdAt={profile.createdAt}
|
createdAt={profile.createdAt}
|
||||||
|
image={profile.image}
|
||||||
|
userId={profile.id}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Shield, Crown, CheckCircle, Mail } from "lucide-react"
|
import { Shield, Crown, CheckCircle, Mail, User } from "lucide-react"
|
||||||
import { motion } from "motion/react"
|
import { motion } from "motion/react"
|
||||||
|
|
||||||
interface ProfileHeaderProps {
|
interface ProfileHeaderProps {
|
||||||
@@ -9,6 +9,7 @@ interface ProfileHeaderProps {
|
|||||||
role: string | null
|
role: string | null
|
||||||
verified: boolean
|
verified: boolean
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
image?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleConfig: Record<string, { label: string; color: string; icon: typeof Crown }> = {
|
const roleConfig: Record<string, { label: string; color: string; icon: typeof Crown }> = {
|
||||||
@@ -17,7 +18,15 @@ const roleConfig: Record<string, { label: string; color: string; icon: typeof Cr
|
|||||||
user: { label: "Member", color: "bg-text/10 text-text/60 border-text/20", icon: Shield },
|
user: { label: "Member", color: "bg-text/10 text-text/60 border-text/20", icon: Shield },
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProfileHeader({ name, email, role, verified, createdAt }: ProfileHeaderProps) {
|
function isR2Avatar(url: string): boolean {
|
||||||
|
return url.includes(".r2.dev")
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitials(name: string): string {
|
||||||
|
return name.charAt(0).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileHeader({ name, email, role, verified, createdAt, image }: ProfileHeaderProps) {
|
||||||
const config = roleConfig[role || "user"] || roleConfig.user
|
const config = roleConfig[role || "user"] || roleConfig.user
|
||||||
const RoleIcon = config.icon
|
const RoleIcon = config.icon
|
||||||
|
|
||||||
@@ -33,6 +42,15 @@ export function ProfileHeader({ name, email, role, verified, createdAt }: Profil
|
|||||||
className="flex flex-col gap-2"
|
className="flex flex-col gap-2"
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{image ? (
|
||||||
|
<div className={`shrink-0 w-16 h-16 rounded-full overflow-hidden ${isR2Avatar(image) ? "ring-2 ring-primary/30 ring-offset-2 ring-offset-background" : ""}`}>
|
||||||
|
<img src={image} alt={`${name}'s profile photo`} className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="shrink-0 w-16 h-16 rounded-full overflow-hidden flex items-center justify-center bg-primary/10 text-primary text-xl font-bold">
|
||||||
|
{getInitials(name)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<h1 className="text-2xl font-bold">{name}</h1>
|
<h1 className="text-2xl font-bold">{name}</h1>
|
||||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${config.color}`}>
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${config.color}`}>
|
||||||
<RoleIcon className="h-3 w-3" />
|
<RoleIcon className="h-3 w-3" />
|
||||||
|
|||||||
@@ -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<string | null>(currentImage)
|
||||||
|
const [uploadState, setUploadState] = useState<"idle" | "uploading" | "success" | "error">("idle")
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const tempUrlRef = useRef<string | null>(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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="rounded-xl border border-border bg-text/[0.03] p-5">
|
||||||
|
<h3 className="text-sm font-medium uppercase tracking-wider text-text/60 mb-4">Profile Photo</h3>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row items-center gap-5">
|
||||||
|
{/* Avatar area */}
|
||||||
|
<div
|
||||||
|
className={`relative shrink-0 rounded-full overflow-hidden w-[128px] h-[128px] cursor-pointer transition-all ${
|
||||||
|
isDragging ? "ring-2 ring-primary ring-offset-2 ring-offset-background" : ""
|
||||||
|
}`}
|
||||||
|
onClick={triggerFileInput}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="Upload profile photo"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault()
|
||||||
|
triggerFileInput()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{previewUrl ? (
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt={`${userName}'s profile photo`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-primary/10 text-primary text-4xl font-bold select-none">
|
||||||
|
{getInitials(userName)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Upload overlay */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 hover:opacity-100 transition-opacity">
|
||||||
|
<Camera className="h-8 w-8 text-white" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{uploadState === "uploading" && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||||
|
<Loader2 className="h-8 w-8 text-white animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={triggerFileInput}
|
||||||
|
className="flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shrink-0 cursor-pointer"
|
||||||
|
disabled={uploadState === "uploading"}
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
Upload Photo
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{showDelete && (
|
||||||
|
<motion.button
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={uploadState === "uploading"}
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-lg border border-border bg-text/5 text-text/60 hover:text-red-400 hover:border-red-400/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||||
|
aria-label="Delete profile photo"
|
||||||
|
title="Delete profile photo"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-text/50">
|
||||||
|
JPEG, PNG, or WebP. Max 5MB.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{errorMessage && (
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: -5 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -5 }}
|
||||||
|
className="text-sm text-red-400"
|
||||||
|
>
|
||||||
|
{errorMessage}
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{uploadState === "success" && !errorMessage && (
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: -5 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="text-sm text-green-400"
|
||||||
|
>
|
||||||
|
Photo updated successfully.
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hidden file input */}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
id="profile-photo-input"
|
||||||
|
/>
|
||||||
|
<label htmlFor="profile-photo-input" className="sr-only">
|
||||||
|
Choose profile photo
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ interface SettingsContainerProps {
|
|||||||
email: string
|
email: string
|
||||||
role: string | null
|
role: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
image?: string | null
|
||||||
|
userId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsContainer({
|
export function SettingsContainer({
|
||||||
@@ -33,6 +35,8 @@ export function SettingsContainer({
|
|||||||
email,
|
email,
|
||||||
role,
|
role,
|
||||||
createdAt,
|
createdAt,
|
||||||
|
image,
|
||||||
|
userId,
|
||||||
}: SettingsContainerProps) {
|
}: SettingsContainerProps) {
|
||||||
const [activeSubTab, setActiveSubTab] = useState<SettingsSubTab>("profile")
|
const [activeSubTab, setActiveSubTab] = useState<SettingsSubTab>("profile")
|
||||||
const [authMethods, setAuthMethods] = useState<AuthMethods | null>(null)
|
const [authMethods, setAuthMethods] = useState<AuthMethods | null>(null)
|
||||||
@@ -120,6 +124,8 @@ export function SettingsContainer({
|
|||||||
email={email}
|
email={email}
|
||||||
role={role}
|
role={role}
|
||||||
createdAt={createdAt}
|
createdAt={createdAt}
|
||||||
|
image={image}
|
||||||
|
userId={userId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeSubTab === "security" && (
|
{activeSubTab === "security" && (
|
||||||
|
|||||||
@@ -5,14 +5,18 @@ import { authClient } from "@/lib/auth-client"
|
|||||||
import { Loader2, Save } from "lucide-react"
|
import { Loader2, Save } from "lucide-react"
|
||||||
import { motion } from "motion/react"
|
import { motion } from "motion/react"
|
||||||
|
|
||||||
|
import { ProfilePhotoUpload } from "@/components/profile/profile-photo-upload"
|
||||||
|
|
||||||
interface SettingsProfileTabProps {
|
interface SettingsProfileTabProps {
|
||||||
name: string
|
name: string
|
||||||
email: string
|
email: string
|
||||||
role: string | null
|
role: string | null
|
||||||
createdAt: string
|
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 [displayName, setDisplayName] = useState(name)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null)
|
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 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="space-y-6"
|
className="space-y-6"
|
||||||
>
|
>
|
||||||
|
{/* Profile Photo */}
|
||||||
|
<ProfilePhotoUpload currentImage={image ?? null} userName={name} userId={userId} />
|
||||||
|
|
||||||
{/* Display Name */}
|
{/* Display Name */}
|
||||||
<div className="rounded-xl border border-border bg-text/[0.03] p-5">
|
<div className="rounded-xl border border-border bg-text/[0.03] p-5">
|
||||||
<h3 className="text-sm font-medium uppercase tracking-wider text-text/60 mb-4">Display Name</h3>
|
<h3 className="text-sm font-medium uppercase tracking-wider text-text/60 mb-4">Display Name</h3>
|
||||||
|
|||||||
Reference in New Issue
Block a user