import { useState, useEffect, useRef } from 'react'; import { useAuthStore } from '../../../stores/authStore'; import { useUIStore } from '../../../stores/uiStore'; import { useInstanceStore } from '../../../stores/instanceStore'; import { Avatar } from '../../ui/Avatar'; import { ImageCropModal } from '../../ui/ImageCropModal'; import { DeleteAccountModal } from '../DeleteAccountModal'; import { api } from '../../../api/client'; import { getAvatarGradient, adjustColor, mutedGradient, AVATAR_GRADIENT_MAP, BANNER_COLOR_PRESETS } from '../../../utils/gradients'; import { AVATAR_COLORS } from '@backspace/shared'; import type { User, UserStatus, AvatarColor } from '@backspace/shared'; import type { FederationOpResult } from '../../../utils/federationOps'; export function AccountPanel() { const user = useAuthStore((s) => s.user); const updateProfile = useAuthStore((s) => s.updateProfile); const [displayName, setDisplayName] = useState(user?.displayName ?? ''); const [customStatus, setCustomStatus] = useState(user?.customStatus ?? ''); const [status, setStatus] = useState(user?.status ?? 'online'); const [bio, setBio] = useState(user?.bio ?? ''); const [accentColor, setAccentColor] = useState(user?.accentColor ?? null); const [avatarColorState, setAvatarColorState] = useState(user?.avatarColor ?? null); const [customHex, setCustomHex] = useState(user?.accentColor ?? ''); // Avatar upload state const [avatarPreview, setAvatarPreview] = useState(null); const [avatarFilename, setAvatarFilename] = useState(null); const [uploadingAvatar, setUploadingAvatar] = useState(false); const [avatarCropSrc, setAvatarCropSrc] = useState(null); const avatarInputRef = useRef(null); // Banner upload state const [bannerPreview, setBannerPreview] = useState(null); const [bannerFilename, setBannerFilename] = useState(null); const [uploadingBanner, setUploadingBanner] = useState(false); const [bannerCropSrc, setBannerCropSrc] = useState(null); const bannerInputRef = useRef(null); const addToast = useUIStore((s) => s.addToast); const [error, setError] = useState(''); const [isLoading, setIsLoading] = useState(false); useEffect(() => { if (user) { setDisplayName(user.displayName ?? ''); setCustomStatus(user.customStatus ?? ''); setStatus(user.status ?? 'online'); setBio(user.bio ?? ''); setAccentColor(user.accentColor ?? null); setAvatarColorState(user.avatarColor ?? null); setCustomHex(user.accentColor ?? ''); // Reset upload state if (avatarPreview) URL.revokeObjectURL(avatarPreview); if (bannerPreview) URL.revokeObjectURL(bannerPreview); setAvatarPreview(null); setAvatarFilename(null); setBannerPreview(null); setBannerFilename(null); } }, [user?.displayName, user?.customStatus, user?.status, user?.bio, user?.accentColor, user?.avatarColor, user?.avatar, user?.banner]); // Password change state const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [confirmNewPassword, setConfirmNewPassword] = useState(''); const [passwordError, setPasswordError] = useState(''); const [passwordLoading, setPasswordLoading] = useState(false); const [passwordResults, setPasswordResults] = useState(null); const [showCurrentPassword, setShowCurrentPassword] = useState(false); const [showNewPassword, setShowNewPassword] = useState(false); // Delete account state const [showDeleteModal, setShowDeleteModal] = useState(false); const instances = useInstanceStore((s) => s.instances); const changePassword = useAuthStore((s) => s.changePassword); if (!user) return null; const effectiveDisplayName = displayName.trim() || user.username; const effectiveAccent = accentColor; const effectiveAvatarColor = avatarColorState; // Change detection const hasChanges = displayName !== (user.displayName ?? '') || customStatus !== (user.customStatus ?? '') || status !== (user.status ?? 'online') || bio !== (user.bio ?? '') || accentColor !== (user.accentColor ?? null) || avatarColorState !== (user.avatarColor ?? null) || avatarFilename !== null || bannerFilename !== null; // Compute banner display const currentBannerUrl = user.banner ? (user.banner.startsWith('http') ? user.banner : api.uploads.url(user.banner)) : null; const displayBannerSrc = bannerPreview ?? (bannerFilename === '' ? null : currentBannerUrl); // Compute avatar display const currentAvatarSrc = user.avatar ? (user.avatar.startsWith('http') ? user.avatar : api.uploads.url(user.avatar)) : null; const displayAvatarSrc = avatarPreview ?? (avatarFilename === '' ? null : currentAvatarSrc); // Banner fallback: accent gradient or avatar gradient (alpha baked into gradient colors) const bannerFallback = effectiveAccent ? mutedGradient(effectiveAccent, adjustColor(effectiveAccent, -40)) : (() => { const g = getAvatarGradient(user.homeUserId ?? user.id, effectiveDisplayName, effectiveAvatarColor); return mutedGradient(g.from, g.to); })(); // ── File selection handlers ── const handleAvatarSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => setAvatarCropSrc(reader.result as string); reader.readAsDataURL(file); if (avatarInputRef.current) avatarInputRef.current.value = ''; }; const handleBannerSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => setBannerCropSrc(reader.result as string); reader.readAsDataURL(file); if (bannerInputRef.current) bannerInputRef.current.value = ''; }; // ── Crop complete handlers ── const handleAvatarCropComplete = async (blob: Blob) => { if (avatarPreview) URL.revokeObjectURL(avatarPreview); const previewUrl = URL.createObjectURL(blob); setAvatarPreview(previewUrl); setAvatarCropSrc(null); const file = new File([blob], 'avatar.webp', { type: blob.type || 'image/webp' }); setUploadingAvatar(true); try { const attachment = await api.uploads.upload(file); setAvatarFilename(attachment.filename); } catch { setError('Failed to upload avatar'); setAvatarPreview(null); URL.revokeObjectURL(previewUrl); } finally { setUploadingAvatar(false); } }; const handleBannerCropComplete = async (blob: Blob) => { if (bannerPreview) URL.revokeObjectURL(bannerPreview); const previewUrl = URL.createObjectURL(blob); setBannerPreview(previewUrl); setBannerCropSrc(null); const file = new File([blob], 'banner.webp', { type: blob.type || 'image/webp' }); setUploadingBanner(true); try { const attachment = await api.uploads.upload(file); setBannerFilename(attachment.filename); } catch { setError('Failed to upload banner'); setBannerPreview(null); URL.revokeObjectURL(previewUrl); } finally { setUploadingBanner(false); } }; const handleRemoveAvatar = () => { if (avatarPreview) URL.revokeObjectURL(avatarPreview); setAvatarPreview(null); setAvatarFilename(''); }; const handleRemoveBanner = () => { if (bannerPreview) URL.revokeObjectURL(bannerPreview); setBannerPreview(null); setBannerFilename(''); }; const handleSave = async () => { setError(''); setIsLoading(true); try { const updates: Record = {}; if (displayName !== (user.displayName ?? '')) updates.displayName = displayName.trim(); if (customStatus !== (user.customStatus ?? '')) updates.customStatus = customStatus.trim(); if (status !== (user.status ?? 'online')) updates.status = status; if (bio !== (user.bio ?? '')) updates.bio = bio.trim(); if (accentColor !== (user.accentColor ?? null)) updates.accentColor = accentColor ?? ''; if (avatarColorState !== (user.avatarColor ?? null)) updates.avatarColor = avatarColorState ?? ''; if (avatarFilename !== null) updates.avatar = avatarFilename; if (bannerFilename !== null) updates.banner = bannerFilename; await updateProfile(updates as Parameters[0]); addToast('Profile updated', 'success', 2000); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to update profile'); } finally { setIsLoading(false); } }; const handleChangePassword = async () => { setPasswordError(''); setPasswordResults(null); if (newPassword.length < 8) { setPasswordError('New password must be at least 8 characters'); return; } if (newPassword !== confirmNewPassword) { setPasswordError('Passwords do not match'); return; } setPasswordLoading(true); try { const results = await changePassword(currentPassword, newPassword); addToast('Password changed', 'success', 2000); setCurrentPassword(''); setNewPassword(''); setConfirmNewPassword(''); if (results.length > 0) { setPasswordResults(results); } setTimeout(() => { setPasswordResults(null); }, 5000); } catch (err) { setPasswordError(err instanceof Error ? err.message : 'Failed to change password'); } finally { setPasswordLoading(false); } }; const handleReset = () => { setDisplayName(user.displayName ?? ''); setCustomStatus(user.customStatus ?? ''); setStatus(user.status ?? 'online'); setBio(user.bio ?? ''); setAccentColor(user.accentColor ?? null); setAvatarColorState(user.avatarColor ?? null); setCustomHex(user.accentColor ?? ''); if (avatarPreview) URL.revokeObjectURL(avatarPreview); if (bannerPreview) URL.revokeObjectURL(bannerPreview); setAvatarPreview(null); setAvatarFilename(null); setBannerPreview(null); setBannerFilename(null); setError(''); }; return (

My Account

{/* ── Profile Customization ── */}
Profile Customization
{/* Live Preview Card */}
{/* Banner area */}
{/* Avatar + info */}
{effectiveDisplayName}
@{user.username}
{bio.trim() && (
{bio.trim()}
)}
{/* Upload controls */}
{/* Avatar upload */}
{(displayAvatarSrc || user.avatar) && avatarFilename !== '' && ( )}
{/* Banner upload */}
{(displayBannerSrc || user.banner) && bannerFilename !== '' && ( )}
{/* Avatar Color */}
{AVATAR_COLORS.map((key) => { const entry = AVATAR_GRADIENT_MAP[key]; return (
{/* Banner Color */}
{[0, 1, 2].map((row) => BANNER_COLOR_PRESETS.map((family) => { const color = family[row]!; return (
{ const val = e.target.value; setCustomHex(val); if (/^#[0-9a-fA-F]{6}$/.test(val)) { setAccentColor(val); } }} placeholder="#hex" className="input-standard w-24 px-2 py-1.5 text-xs font-mono" maxLength={7} /> {accentColor && (
)} {accentColor && ( )}
{/* Bio */}