veza/apps/web/src/components/settings/profile/EditProfile.tsx
senke 3fb12b2ce2 aesthetic-improvements: automated replacement of decorative cyan with steel (80/20 rule, Action 11.3.1.3)
- Created automated script (scripts/replace-decorative-cyan.py) to systematically replace decorative/informational kodo-cyan instances with kodo-steel variants
- Script intelligently preserves active/functional states, design system variants, semantic indicators, and interactive states
- Modified 85 files, replaced 145 decorative instances, preserved 47 functional instances
- No linter errors, type safety maintained
- Action 11.3.1.3 significantly advanced (total: ~302 instances replaced across ~229 files including previous batches)
2026-01-16 11:40:13 +01:00

310 lines
10 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { Card } from '../../ui/card';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { Upload, Camera, Save } from 'lucide-react';
import { ImageCropper } from '../../ui/ImageCropper';
import { useToast } from '../../../context/ToastContext';
import { useAuth } from '../../../context/AuthContext';
import { userService } from '../../../services/userService';
import { logger } from '@/utils/logger';
// Utilities for Canvas Cropping (keeping helper)
const createImage = (url: string): Promise<HTMLImageElement> =>
new Promise((resolve, reject) => {
const image = new Image();
image.addEventListener('load', () => resolve(image));
image.addEventListener('error', (error) => reject(error));
image.setAttribute('crossOrigin', 'anonymous');
image.src = url;
});
async function getCroppedImg(
imageSrc: string,
pixelCrop: any,
): Promise<string> {
const image = await createImage(imageSrc);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return '';
canvas.width = pixelCrop.width;
canvas.height = pixelCrop.height;
ctx.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
pixelCrop.width,
pixelCrop.height,
);
return new Promise((resolve) => {
canvas.toBlob((blob) => {
if (!blob) return;
resolve(URL.createObjectURL(blob));
}, 'image/jpeg');
});
}
export const EditProfile: React.FC = () => {
const { user } = useAuth();
const { addToast } = useToast();
// State: Images
const [avatar, setAvatar] = useState('https://via.placeholder.com/400');
const [banner, setBanner] = useState('https://via.placeholder.com/1200x400');
const [cropImage, setCropImage] = useState<string | null>(null);
const [cropType, setCropType] = useState<'avatar' | 'banner' | null>(null);
const [loading, setLoading] = useState(false);
// State: Form
const [formData, setFormData] = useState({
username: '',
first_name: '',
last_name: '',
bio: '',
location: '',
gender: 'Prefer not to say',
birthdate: '',
});
// Fetch initial data
useEffect(() => {
const fetchProfile = async () => {
if (!user) return;
try {
const res = await userService.getProfile(user.id);
const p = res.profile;
setFormData({
username: p.username || '',
first_name: p.first_name || '',
last_name: p.last_name || '',
bio: p.bio || '',
location: p.location || '',
gender: p.gender || 'Prefer not to say',
birthdate: p.birthdate || '',
});
if (p.avatar) setAvatar(p.avatar);
if (p.banner) setBanner(p.banner);
} catch (e) {
logger.error('Failed to load profile settings', {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
userId: user?.id,
});
addToast('Failed to load profile settings', 'error');
}
};
fetchProfile();
}, [user]);
const handleSave = async () => {
if (!user) return;
setLoading(true);
try {
await userService.updateProfile(user.id, formData);
addToast('Profile updated successfully', 'success');
} catch (e) {
addToast('Failed to update profile', 'error');
} finally {
setLoading(false);
}
};
const handleFileChange = (
e: React.ChangeEvent<HTMLInputElement>,
type: 'avatar' | 'banner',
) => {
if (e.target.files && e.target.files.length > 0) {
const file = e.target.files[0];
const imageDataUrl = URL.createObjectURL(file);
setCropImage(imageDataUrl);
setCropType(type);
}
};
const handleCropComplete = async (croppedAreaPixels: any) => {
if (cropImage && cropType) {
try {
const croppedImage = await getCroppedImg(cropImage, croppedAreaPixels);
if (cropType === 'avatar') setAvatar(croppedImage);
else setBanner(croppedImage);
setCropImage(null);
setCropType(null);
addToast('Image cropped (Need backend upload to persist)', 'info');
} catch (e) {
addToast('Failed to crop image', 'error');
}
}
};
return (
<div className="space-y-8 animate-fadeIn max-w-4xl mx-auto">
{/* 1. IMAGES SECTION */}
<Card variant="default" className="p-0 overflow-hidden relative group">
{/* Banner */}
<div className="h-48 md:h-64 bg-kodo-ink relative">
<img src={banner} className="w-full h-full object-cover opacity-80" />
<div className="absolute inset-0 bg-black/20 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<label className="cursor-pointer bg-black/60 hover:bg-black/80 text-white px-4 py-2 rounded-lg flex items-center gap-2 backdrop-blur border border-white/10">
<Camera className="w-4 h-4" /> Change Banner
<input
type="file"
className="hidden"
accept="image/*"
onChange={(e) => handleFileChange(e, 'banner')}
/>
</label>
</div>
</div>
{/* Avatar */}
<div className="px-8 relative -mt-16 mb-6 flex items-end justify-between">
<div className="relative group/avatar">
<div className="w-32 h-32 rounded-full border-4 border-kodo-graphite bg-black overflow-hidden relative">
<img src={avatar} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover/avatar:opacity-100 transition-opacity">
<label className="cursor-pointer p-2 bg-black/50 rounded-full hover:bg-black/70 text-white">
<Upload className="w-5 h-5" />
<input
type="file"
className="hidden"
accept="image/*"
onChange={(e) => handleFileChange(e, 'avatar')}
/>
</label>
</div>
</div>
</div>
<Button
variant="primary"
icon={<Save className="w-4 h-4" />}
onClick={handleSave}
disabled={loading}
>
{loading ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* 2. MAIN FORM */}
<div className="lg:col-span-2 space-y-6">
<Card variant="default">
<h3 className="font-bold text-white mb-6 border-b border-kodo-steel pb-2">
Identity
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<Input
label="Username"
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<Input
label="First Name"
value={formData.first_name}
onChange={(e) =>
setFormData({ ...formData, first_name: e.target.value })
}
/>
<Input
label="Last Name"
value={formData.last_name}
onChange={(e) =>
setFormData({ ...formData, last_name: e.target.value })
}
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-kodo-content-dim mb-2">
Bio
</label>
<textarea
className="w-full bg-kodo-graphite border border-kodo-steel rounded-lg p-3 text-white focus:border-kodo-steel outline-none min-h-[100px]"
value={formData.bio}
onChange={(e) =>
setFormData({ ...formData, bio: e.target.value })
}
maxLength={500}
/>
<p className="text-xs text-kodo-content-dim text-right mt-1">
{formData.bio.length}/500
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Location"
value={formData.location}
onChange={(e) =>
setFormData({ ...formData, location: e.target.value })
}
/>
<div>
<label className="block text-sm font-medium text-kodo-content-dim mb-2">
Gender
</label>
<select
className="w-full bg-kodo-graphite border border-kodo-steel rounded-lg p-3 text-white focus:border-kodo-steel outline-none"
value={formData.gender}
onChange={(e) =>
setFormData({ ...formData, gender: e.target.value })
}
>
<option>Male</option>
<option>Female</option>
<option>Other</option>
<option>Prefer not to say</option>
</select>
</div>
</div>
</Card>
</div>
{/* 3. SIDEBAR */}
<div className="space-y-6">
<Card variant="gaming">
<h3 className="font-bold text-white mb-4 text-sm uppercase tracking-wider">
Verification
</h3>
<p className="text-xs text-kodo-content-dim mb-4">
Complete your profile to get verified.
</p>
<Button
variant="secondary"
size="sm"
className="w-full"
onClick={() => addToast('Verification request sent')}
>
Request Verification
</Button>
</Card>
</div>
</div>
{/* Crop Modal */}
{cropImage && cropType && (
<ImageCropper
imageSrc={cropImage}
aspectRatio={cropType === 'avatar' ? 1 : 3}
circularCrop={cropType === 'avatar'}
onCancel={() => {
setCropImage(null);
setCropType(null);
}}
onCropComplete={handleCropComplete}
/>
)}
</div>
);
};