Files
forkmessager/client-web/src/modules/users/presentation/components/SettingsPage.tsx
Халимов Рустам e11240f78f Аватар
2026-04-05 01:27:58 +03:00

330 lines
15 KiB
TypeScript

import { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
User, Camera, Edit3, Calendar, Info,
MapPin, AtSign, Check, Loader2, LogOut,
ChevronRight, ArrowLeft, Languages, Palette, Trash2,
Bell, Shield, Eye
} from 'lucide-react';
import { useAuthStore } from '../../../auth/application/authStore';
import { useLang } from '../../../../core/infrastructure/i18n';
import { UserApi } from '../../infrastructure/userApi';
import { getMediaUrl, getInitials } from '../../../../core/utils/utils';
import DatePicker from '../../../../core/presentation/components/ui/DatePicker';
import AvatarCropModal from './AvatarCropModal';
import { Area } from 'react-easy-crop';
import { getCroppedImg } from '../../../../core/utils/cropImage';
export default function SettingsPage() {
const { user, updateUser, logout } = useAuthStore();
const { t, lang, setLang } = useLang();
const [editing, setEditing] = useState(false);
const [formData, setFormData] = useState({
displayName: user?.displayName || '',
bio: user?.bio || '',
birthday: user?.birthday || ''
});
const [saving, setSaving] = useState(false);
// Avatar cropping state
const [cropModal, setCropModal] = useState<{
open: boolean,
image: string,
file: File | null
}>({ open: false, image: '', file: null });
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (user) {
setFormData({
displayName: user.displayName || '',
bio: user.bio || '',
birthday: user.birthday || ''
});
}
}, [user]);
const handleSaveProfile = async () => {
setSaving(true);
try {
const payload = {
...formData,
birthday: formData.birthday || null
};
const updatedUser = await UserApi.updateProfile(payload);
updateUser(updatedUser);
setEditing(false);
} catch (err) {
console.error(err);
} finally {
setSaving(false);
}
};
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = () => {
setCropModal({
open: true,
image: reader.result as string,
file
});
};
reader.readAsDataURL(file);
}
e.target.value = '';
};
const onCropConfirm = async (_area: Area, pixelArea: Area) => {
if (!cropModal.file || !cropModal.image) return;
setCropModal(prev => ({ ...prev, open: false }));
setUploadingAvatar(true);
try {
const croppedBlob = await getCroppedImg(cropModal.image, pixelArea);
if (!croppedBlob) throw new Error('Failed to crop image');
const croppedFile = new File([croppedBlob], 'avatar.jpg', { type: 'image/jpeg' });
const updatedUser = await UserApi.uploadAvatar(croppedFile);
updateUser(updatedUser);
} catch (err) {
console.error('Failed to update avatar:', err);
} finally {
setUploadingAvatar(false);
}
};
const removeAvatar = async () => {
try {
const updatedUser = await UserApi.removeAvatar();
updateUser(updatedUser);
} catch (err) {
console.error(err);
}
}
const initials = getInitials(user?.displayName || user?.username || '??');
return (
<div className="flex-1 h-full overflow-y-auto px-12 py-16 custom-scrollbar bg-surface-container-lowest">
<div className="max-w-[700px] mx-auto space-y-12">
{/* Header Section */}
<div className="flex items-end justify-between border-b border-white/[0.04] pb-8">
<div>
<h1 className="text-4xl font-headline font-black text-white mb-2 leading-none uppercase tracking-tighter">
{t('myProfile') || 'My Profile'}
</h1>
<p className="text-zinc-500 text-sm font-medium tracking-tight">{t('settingsDesc')}</p>
</div>
{!editing ? (
<button
onClick={() => setEditing(true)}
className="px-6 py-2.5 rounded-2xl bg-white/5 border border-white/10 text-white text-xs font-black uppercase tracking-widest hover:bg-primary hover:text-on-primary hover:border-primary transition-all flex items-center gap-2 group"
>
<Edit3 size={14} className="group-hover:scale-110 transition-transform" />
{t('edit')}
</button>
) : (
<div className="flex items-center gap-3">
<button
onClick={() => setEditing(false)}
className="px-6 py-2.5 rounded-2xl text-on-surface-variant text-xs font-black uppercase tracking-widest hover:text-white transition-colors"
>
{t('cancel')}
</button>
<button
onClick={handleSaveProfile}
disabled={saving}
className="px-8 py-2.5 rounded-2xl bg-primary text-on-primary text-xs font-black uppercase tracking-widest shadow-lg shadow-primary/20 hover:scale-[1.03] active:scale-95 transition-all flex items-center gap-3"
>
{saving && <Loader2 size={14} className="animate-spin" />}
{t('save')}
</button>
</div>
)}
</div>
{/* Profile/Avatar Section */}
<div className="flex flex-col md:flex-row gap-12 items-start py-4">
<div className="relative group">
{/* Avatar Glow */}
<div className={`absolute -inset-4 bg-primary/20 blur-3xl rounded-full opacity-40 group-hover:opacity-60 transition-opacity ${uploadingAvatar ? 'animate-pulse' : ''}`} />
<div className="relative w-48 h-48 rounded-[3rem] bg-zinc-900 border-2 border-white/10 p-1.5 overflow-hidden shadow-2xl transition-transform duration-500 group-hover:scale-[1.02]">
<div className="relative w-full h-full rounded-[2.5rem] overflow-hidden bg-surface-container flex items-center justify-center">
{user?.avatar ? (
<img
src={getMediaUrl(user.avatar)}
className={`w-full h-full object-cover transition-opacity duration-500 ${uploadingAvatar ? 'opacity-40' : 'opacity-100'}`}
alt=""
/>
) : (
<div className="w-full h-full bg-linear-to-br from-primary/30 to-primary-container/10 flex items-center justify-center text-5xl font-black text-primary">
{initials}
</div>
)}
{uploadingAvatar && (
<div className="absolute inset-0 flex items-center justify-center bg-black/20 backdrop-blur-[2px]">
<Loader2 size={32} className="text-primary animate-spin" />
</div>
)}
{/* Overlay on hover */}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-3">
<button
onClick={() => fileInputRef.current?.click()}
className="p-3 rounded-full bg-primary text-on-primary shadow-xl scale-90 group-hover:scale-100 transition-all"
>
<Camera size={24} />
</button>
{user?.avatar && (
<button
onClick={removeAvatar}
className="p-2 text-white/40 hover:text-red-400 transition-colors"
>
<Trash2 size={18} />
</button>
)}
</div>
</div>
</div>
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleAvatarChange}
/>
</div>
<div className="flex-1 space-y-8 w-full">
<div className="space-y-1.5">
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-on-surface-variant opacity-40">{t('displayName')}</label>
{editing ? (
<input
type="text"
value={formData.displayName}
onChange={(e) => setFormData(p => ({ ...p, displayName: e.target.value }))}
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-3 text-lg font-bold text-white focus:border-primary/50 transition-all outline-none"
placeholder={t('enterDisplayName')}
/>
) : (
<p className="text-3xl font-black text-white tracking-tighter">{user?.displayName}</p>
)}
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2 text-on-surface-variant opacity-40">
<AtSign size={12} />
<label className="text-[10px] font-black uppercase tracking-[0.2em]">{t('username')}</label>
</div>
<p className="text-base font-bold text-zinc-400">@{user?.username || user?.userName}</p>
</div>
</div>
</div>
{/* Bio & Details Section */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-3 p-8 rounded-[2.5rem] bg-white/[0.02] border border-white/5 transition-colors hover:bg-white/[0.04]">
<div className="flex items-center gap-3 text-primary mb-1">
<Info size={16} />
<span className="text-[10px] font-black uppercase tracking-[0.2em]">{t('aboutMe')}</span>
</div>
{editing ? (
<textarea
value={formData.bio}
onChange={(e) => setFormData(p => ({ ...p, bio: e.target.value }))}
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-3 text-sm font-medium text-white/80 focus:border-primary/50 transition-all outline-none resize-none h-32"
placeholder={t('tellAboutBio')}
/>
) : (
<p className="text-base text-white/70 leading-relaxed font-medium min-h-[4rem]">
{user?.bio || t('noBio')}
</p>
)}
</div>
<div className="space-y-3 p-8 rounded-[2.5rem] bg-white/[0.02] border border-white/5 transition-colors hover:bg-white/[0.04]">
<div className="flex items-center gap-3 text-primary mb-1">
<Calendar size={16} />
<span className="text-[10px] font-black uppercase tracking-[0.2em]">{t('birthday')}</span>
</div>
{editing ? (
<DatePicker
value={formData.birthday}
onChange={(val) => setFormData(p => ({ ...p, birthday: val }))}
/>
) : (
<p className="text-base text-white/70 leading-relaxed font-black uppercase tracking-widest">
{user?.birthday ? new Date(user.birthday).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'long', year: 'numeric' }) : t('notSpecified')}
</p>
)}
</div>
</div>
{/* App Settings */}
<div className="space-y-6 pt-6">
<div className="flex items-center gap-4 mb-4">
<div className="w-px h-6 bg-primary/40 rounded-full" />
<h2 className="text-sm font-black uppercase tracking-[0.3em] text-white/40">{t('appSettings')}</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Language Picker */}
<div className="p-6 rounded-3xl bg-white/[0.02] border border-white/5 space-y-4">
<div className="flex items-center gap-3 opacity-60">
<Languages size={16} className="text-zinc-500" />
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400">{t('language')}</span>
</div>
<div className="flex gap-2 p-1.5 rounded-2xl bg-black/20 w-fit">
<button
onClick={() => setLang('ru')}
className={`px-4 py-2 rounded-[14px] text-[11px] font-black uppercase tracking-widest transition-all ${lang === 'ru' ? 'bg-primary text-on-primary shadow-lg shadow-primary/20' : 'text-zinc-500 hover:text-white'}`}
>
RU
</button>
<button
onClick={() => setLang('en')}
className={`px-4 py-2 rounded-[14px] text-[11px] font-black uppercase tracking-widest transition-all ${lang === 'en' ? 'bg-primary text-on-primary shadow-lg shadow-primary/20' : 'text-zinc-500 hover:text-white'}`}
>
EN
</button>
</div>
</div>
{/* Account Section */}
<div className="p-6 rounded-3xl bg-white/[0.02] border border-white/5 flex flex-col justify-between">
<button
onClick={() => { logout(); }}
className="w-full flex items-center justify-between p-4 rounded-2xl bg-red-500/5 hover:bg-red-500/15 border border-red-500/10 transition-all group"
>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-red-500/10 flex items-center justify-center text-red-500 group-hover:scale-105 transition-transform">
<LogOut size={16} />
</div>
<span className="text-[11px] font-black uppercase tracking-widest text-red-500/70 group-hover:text-red-500 transition-colors">{t('logout')}</span>
</div>
<ChevronRight size={16} className="text-red-500/40" />
</button>
</div>
</div>
</div>
</div>
<AnimatePresence>
{cropModal.open && (
<AvatarCropModal
image={cropModal.image}
onCrop={onCropConfirm}
onClose={() => setCropModal({ open: false, image: '', file: null })}
/>
)}
</AnimatePresence>
</div>
);
}