OpenMAIC-React / src /components /user-profile.tsx
muthuk1's picture
Convert OpenMAIC from Next.js to React (Vite)
f56a29b verified
import { useState, useEffect, useRef } from 'react';
import { Pencil, Check, ImagePlus, ChevronDown } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { Card } from '@/components/ui/card';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/hooks/use-i18n';
import { toast } from 'sonner';
import { useUserProfileStore, AVATAR_OPTIONS } from '@/lib/store/user-profile';
/** Check whether avatar is a custom upload (data-URL) */
function isCustomAvatar(avatar: string) {
return avatar.startsWith('data:');
}
/** Max uploaded image size before we reject */
const MAX_AVATAR_SIZE = 5 * 1024 * 1024; // 5 MB
const FILE_INPUT_ID = 'user-avatar-upload';
export function UserProfileCard() {
const { t } = useI18n();
const avatar = useUserProfileStore((s) => s.avatar);
const nickname = useUserProfileStore((s) => s.nickname);
const bio = useUserProfileStore((s) => s.bio);
const setAvatar = useUserProfileStore((s) => s.setAvatar);
const setNickname = useUserProfileStore((s) => s.setNickname);
const setBio = useUserProfileStore((s) => s.setBio);
const [editingName, setEditingName] = useState(false);
const [nameDraft, setNameDraft] = useState('');
const [avatarPickerOpen, setAvatarPickerOpen] = useState(false);
const [hydrated, setHydrated] = useState(false);
const nameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setHydrated(true); // eslint-disable-line react-hooks/set-state-in-effect -- Store hydration on mount
}, []);
useEffect(() => {
if (editingName) nameInputRef.current?.focus();
}, [editingName]);
const displayName = nickname || t('profile.defaultNickname');
const startEditName = () => {
setNameDraft(nickname);
setEditingName(true);
};
const commitName = () => {
setNickname(nameDraft.trim());
setEditingName(false);
};
const handleAvatarUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > MAX_AVATAR_SIZE) {
toast.error(t('profile.fileTooLarge'));
return;
}
if (!file.type.startsWith('image/')) {
toast.error(t('profile.invalidFileType'));
return;
}
const reader = new FileReader();
reader.onload = () => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = 128;
canvas.height = 128;
const ctx = canvas.getContext('2d')!;
const scale = Math.max(128 / img.width, 128 / img.height);
const w = img.width * scale;
const h = img.height * scale;
ctx.drawImage(img, (128 - w) / 2, (128 - h) / 2, w, h);
setAvatar(canvas.toDataURL('image/jpeg', 0.85));
};
img.src = reader.result as string;
};
reader.readAsDataURL(file);
e.target.value = '';
};
if (!hydrated) {
return (
<Card className="p-5 !gap-0 shadow-xl border-muted/40 backdrop-blur-xl bg-white/80 dark:bg-slate-900/80">
<div className="flex items-center gap-3">
<div className="size-11 rounded-full bg-muted animate-pulse" />
<div className="flex-1 space-y-2">
<div className="h-3 w-16 rounded bg-muted animate-pulse" />
<div className="h-4 w-24 rounded bg-muted animate-pulse" />
</div>
</div>
</Card>
);
}
return (
<Card className="p-5 !gap-0 shadow-xl border-muted/40 backdrop-blur-xl bg-white/80 dark:bg-slate-900/80">
{/* File input — sr-only keeps it in the flow but invisible; label triggers it */}
<input
id={FILE_INPUT_ID}
type="file"
accept="image/*"
className="sr-only"
onChange={handleAvatarUpload}
/>
{/* Row 1: Avatar + Name */}
<div className="flex items-center gap-3.5">
{/* Avatar — click to toggle picker */}
<button
onClick={() => setAvatarPickerOpen(!avatarPickerOpen)}
className="shrink-0 group/avatar relative cursor-pointer"
>
<div className="size-11 rounded-full bg-gray-50 dark:bg-gray-800 overflow-hidden ring-2 ring-violet-300/50 dark:ring-violet-600/40 group-hover/avatar:ring-violet-400 dark:group-hover/avatar:ring-violet-500 transition-all">
<img src={avatar} alt="" className="size-full object-cover" />
</div>
<div className="absolute -bottom-0.5 -right-0.5 size-4 rounded-full bg-white dark:bg-slate-800 border border-muted/60 flex items-center justify-center">
<ChevronDown
className={cn(
'size-2.5 text-muted-foreground transition-transform duration-200',
avatarPickerOpen && 'rotate-180',
)}
/>
</div>
</button>
{/* Name */}
<div className="flex-1 min-w-0">
{editingName ? (
<div className="flex items-center gap-1.5">
<input
ref={nameInputRef}
value={nameDraft}
onChange={(e) => setNameDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') commitName();
if (e.key === 'Escape') setEditingName(false);
}}
onBlur={commitName}
maxLength={20}
placeholder={t('profile.defaultNickname')}
className="flex-1 min-w-0 h-7 bg-transparent border-b-2 border-violet-400 dark:border-violet-500 text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground/40"
/>
<button
onClick={commitName}
className="shrink-0 size-6 rounded-md flex items-center justify-center text-violet-500 hover:bg-violet-100 dark:hover:bg-violet-900/30 transition-colors"
>
<Check className="size-3.5" />
</button>
</div>
) : (
<button
onClick={startEditName}
className="group/name flex items-center gap-1.5 cursor-pointer"
>
<span className="text-sm font-semibold text-foreground truncate">{displayName}</span>
<Pencil className="size-3 text-muted-foreground/40 opacity-0 group-hover/name:opacity-100 transition-opacity" />
</button>
)}
<p className="text-[10px] text-muted-foreground/50 mt-0.5">{t('profile.avatarHint')}</p>
</div>
</div>
{/* Avatar picker — collapsible */}
<AnimatePresence>
{avatarPickerOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
className="overflow-hidden"
>
{/* p-1 gives breathing room so ring-offset / hover-scale aren't clipped */}
<div className="pt-3 pb-1 px-1 flex items-center gap-1.5 flex-wrap">
{AVATAR_OPTIONS.map((url) => (
<button
key={url}
onClick={() => setAvatar(url)}
className={cn(
'size-8 rounded-full overflow-hidden bg-gray-50 dark:bg-gray-800 cursor-pointer transition-all duration-150',
'hover:scale-110 active:scale-95',
avatar === url
? 'ring-2 ring-violet-400 dark:ring-violet-500 ring-offset-1 ring-offset-white dark:ring-offset-slate-900'
: 'hover:ring-1 hover:ring-muted-foreground/30',
)}
>
<img src={url} alt="" className="size-full" />
</button>
))}
{/* Upload — uses <label htmlFor> to natively trigger the file input */}
<label
htmlFor={FILE_INPUT_ID}
className={cn(
'size-8 rounded-full flex items-center justify-center cursor-pointer transition-all duration-150 border border-dashed',
'hover:scale-110 active:scale-95',
isCustomAvatar(avatar)
? 'ring-2 ring-violet-400 dark:ring-violet-500 ring-offset-1 ring-offset-white dark:ring-offset-slate-900 border-violet-300 dark:border-violet-600 bg-violet-50 dark:bg-violet-900/30'
: 'border-muted-foreground/30 text-muted-foreground/50 hover:border-muted-foreground/50',
)}
title={t('profile.uploadAvatar')}
>
<ImagePlus className="size-3.5" />
</label>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Bio input */}
<Textarea
value={bio}
onChange={(e) => setBio(e.target.value)}
placeholder={t('profile.bioPlaceholder')}
maxLength={200}
rows={3}
className="mt-3 resize-none bg-background/50 min-h-[80px]"
/>
</Card>
);
}