| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230 |
- 'use client';
- 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>
- );
- }
|