user-profile.tsx 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. 'use client';
  2. import { useState, useEffect, useRef } from 'react';
  3. import { Pencil, Check, ImagePlus, ChevronDown } from 'lucide-react';
  4. import { AnimatePresence, motion } from 'motion/react';
  5. import { Card } from '@/components/ui/card';
  6. import { Textarea } from '@/components/ui/textarea';
  7. import { cn } from '@/lib/utils';
  8. import { useI18n } from '@/lib/hooks/use-i18n';
  9. import { toast } from 'sonner';
  10. import { useUserProfileStore, AVATAR_OPTIONS } from '@/lib/store/user-profile';
  11. /** Check whether avatar is a custom upload (data-URL) */
  12. function isCustomAvatar(avatar: string) {
  13. return avatar.startsWith('data:');
  14. }
  15. /** Max uploaded image size before we reject */
  16. const MAX_AVATAR_SIZE = 5 * 1024 * 1024; // 5 MB
  17. const FILE_INPUT_ID = 'user-avatar-upload';
  18. export function UserProfileCard() {
  19. const { t } = useI18n();
  20. const avatar = useUserProfileStore((s) => s.avatar);
  21. const nickname = useUserProfileStore((s) => s.nickname);
  22. const bio = useUserProfileStore((s) => s.bio);
  23. const setAvatar = useUserProfileStore((s) => s.setAvatar);
  24. const setNickname = useUserProfileStore((s) => s.setNickname);
  25. const setBio = useUserProfileStore((s) => s.setBio);
  26. const [editingName, setEditingName] = useState(false);
  27. const [nameDraft, setNameDraft] = useState('');
  28. const [avatarPickerOpen, setAvatarPickerOpen] = useState(false);
  29. const [hydrated, setHydrated] = useState(false);
  30. const nameInputRef = useRef<HTMLInputElement>(null);
  31. useEffect(() => {
  32. setHydrated(true); // eslint-disable-line react-hooks/set-state-in-effect -- Store hydration on mount
  33. }, []);
  34. useEffect(() => {
  35. if (editingName) nameInputRef.current?.focus();
  36. }, [editingName]);
  37. const displayName = nickname || t('profile.defaultNickname');
  38. const startEditName = () => {
  39. setNameDraft(nickname);
  40. setEditingName(true);
  41. };
  42. const commitName = () => {
  43. setNickname(nameDraft.trim());
  44. setEditingName(false);
  45. };
  46. const handleAvatarUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
  47. const file = e.target.files?.[0];
  48. if (!file) return;
  49. if (file.size > MAX_AVATAR_SIZE) {
  50. toast.error(t('profile.fileTooLarge'));
  51. return;
  52. }
  53. if (!file.type.startsWith('image/')) {
  54. toast.error(t('profile.invalidFileType'));
  55. return;
  56. }
  57. const reader = new FileReader();
  58. reader.onload = () => {
  59. const img = new Image();
  60. img.onload = () => {
  61. const canvas = document.createElement('canvas');
  62. canvas.width = 128;
  63. canvas.height = 128;
  64. const ctx = canvas.getContext('2d')!;
  65. const scale = Math.max(128 / img.width, 128 / img.height);
  66. const w = img.width * scale;
  67. const h = img.height * scale;
  68. ctx.drawImage(img, (128 - w) / 2, (128 - h) / 2, w, h);
  69. setAvatar(canvas.toDataURL('image/jpeg', 0.85));
  70. };
  71. img.src = reader.result as string;
  72. };
  73. reader.readAsDataURL(file);
  74. e.target.value = '';
  75. };
  76. if (!hydrated) {
  77. return (
  78. <Card className="p-5 !gap-0 shadow-xl border-muted/40 backdrop-blur-xl bg-white/80 dark:bg-slate-900/80">
  79. <div className="flex items-center gap-3">
  80. <div className="size-11 rounded-full bg-muted animate-pulse" />
  81. <div className="flex-1 space-y-2">
  82. <div className="h-3 w-16 rounded bg-muted animate-pulse" />
  83. <div className="h-4 w-24 rounded bg-muted animate-pulse" />
  84. </div>
  85. </div>
  86. </Card>
  87. );
  88. }
  89. return (
  90. <Card className="p-5 !gap-0 shadow-xl border-muted/40 backdrop-blur-xl bg-white/80 dark:bg-slate-900/80">
  91. {/* File input — sr-only keeps it in the flow but invisible; label triggers it */}
  92. <input
  93. id={FILE_INPUT_ID}
  94. type="file"
  95. accept="image/*"
  96. className="sr-only"
  97. onChange={handleAvatarUpload}
  98. />
  99. {/* Row 1: Avatar + Name */}
  100. <div className="flex items-center gap-3.5">
  101. {/* Avatar — click to toggle picker */}
  102. <button
  103. onClick={() => setAvatarPickerOpen(!avatarPickerOpen)}
  104. className="shrink-0 group/avatar relative cursor-pointer"
  105. >
  106. <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">
  107. <img src={avatar} alt="" className="size-full object-cover" />
  108. </div>
  109. <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">
  110. <ChevronDown
  111. className={cn(
  112. 'size-2.5 text-muted-foreground transition-transform duration-200',
  113. avatarPickerOpen && 'rotate-180',
  114. )}
  115. />
  116. </div>
  117. </button>
  118. {/* Name */}
  119. <div className="flex-1 min-w-0">
  120. {editingName ? (
  121. <div className="flex items-center gap-1.5">
  122. <input
  123. ref={nameInputRef}
  124. value={nameDraft}
  125. onChange={(e) => setNameDraft(e.target.value)}
  126. onKeyDown={(e) => {
  127. if (e.key === 'Enter') commitName();
  128. if (e.key === 'Escape') setEditingName(false);
  129. }}
  130. onBlur={commitName}
  131. maxLength={20}
  132. placeholder={t('profile.defaultNickname')}
  133. 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"
  134. />
  135. <button
  136. onClick={commitName}
  137. 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"
  138. >
  139. <Check className="size-3.5" />
  140. </button>
  141. </div>
  142. ) : (
  143. <button
  144. onClick={startEditName}
  145. className="group/name flex items-center gap-1.5 cursor-pointer"
  146. >
  147. <span className="text-sm font-semibold text-foreground truncate">{displayName}</span>
  148. <Pencil className="size-3 text-muted-foreground/40 opacity-0 group-hover/name:opacity-100 transition-opacity" />
  149. </button>
  150. )}
  151. <p className="text-[10px] text-muted-foreground/50 mt-0.5">{t('profile.avatarHint')}</p>
  152. </div>
  153. </div>
  154. {/* Avatar picker — collapsible */}
  155. <AnimatePresence>
  156. {avatarPickerOpen && (
  157. <motion.div
  158. initial={{ height: 0, opacity: 0 }}
  159. animate={{ height: 'auto', opacity: 1 }}
  160. exit={{ height: 0, opacity: 0 }}
  161. transition={{ duration: 0.2, ease: 'easeInOut' }}
  162. className="overflow-hidden"
  163. >
  164. {/* p-1 gives breathing room so ring-offset / hover-scale aren't clipped */}
  165. <div className="pt-3 pb-1 px-1 flex items-center gap-1.5 flex-wrap">
  166. {AVATAR_OPTIONS.map((url) => (
  167. <button
  168. key={url}
  169. onClick={() => setAvatar(url)}
  170. className={cn(
  171. 'size-8 rounded-full overflow-hidden bg-gray-50 dark:bg-gray-800 cursor-pointer transition-all duration-150',
  172. 'hover:scale-110 active:scale-95',
  173. avatar === url
  174. ? 'ring-2 ring-violet-400 dark:ring-violet-500 ring-offset-1 ring-offset-white dark:ring-offset-slate-900'
  175. : 'hover:ring-1 hover:ring-muted-foreground/30',
  176. )}
  177. >
  178. <img src={url} alt="" className="size-full" />
  179. </button>
  180. ))}
  181. {/* Upload — uses <label htmlFor> to natively trigger the file input */}
  182. <label
  183. htmlFor={FILE_INPUT_ID}
  184. className={cn(
  185. 'size-8 rounded-full flex items-center justify-center cursor-pointer transition-all duration-150 border border-dashed',
  186. 'hover:scale-110 active:scale-95',
  187. isCustomAvatar(avatar)
  188. ? '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'
  189. : 'border-muted-foreground/30 text-muted-foreground/50 hover:border-muted-foreground/50',
  190. )}
  191. title={t('profile.uploadAvatar')}
  192. >
  193. <ImagePlus className="size-3.5" />
  194. </label>
  195. </div>
  196. </motion.div>
  197. )}
  198. </AnimatePresence>
  199. {/* Bio input */}
  200. <Textarea
  201. value={bio}
  202. onChange={(e) => setBio(e.target.value)}
  203. placeholder={t('profile.bioPlaceholder')}
  204. maxLength={200}
  205. rows={3}
  206. className="mt-3 resize-none bg-background/50 min-h-[80px]"
  207. />
  208. </Card>
  209. );
  210. }