- 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)
310 lines
10 KiB
TypeScript
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>
|
|
);
|
|
};
|