330 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|