2026-05-09 18:23:21 +08:00
|
|
|
import { useState, useEffect, useRef } from 'react';
|
|
|
|
|
import { useNavigate } from 'react-router-dom';
|
|
|
|
|
import { getUserProfile, updateUserProfile, uploadAvatar, type UserProfile } from '../lib/api';
|
|
|
|
|
import { getAuth } from '../lib/auth';
|
2026-05-09 16:00:29 +08:00
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
locale: string;
|
|
|
|
|
dashboard: { brandName: string; navHome: string; navStore: string; navDivination: string; navManual: string; navAuto: string; navHistory: string; navLanguage: string; navSettings: string; logout: string };
|
|
|
|
|
profile: { avatarTitle: string; avatarHint: string; uploadBtn: string; formTitle: string; emailLabel: string; displayNameLabel: string; displayNamePlaceholder: string; bioLabel: string; bioPlaceholder: string; saveBtn: string; cancelBtn: string };
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 18:23:21 +08:00
|
|
|
// Compress image before upload
|
|
|
|
|
async function compressImage(file: File, maxWidth = 512, maxHeight = 512, quality = 0.8): Promise<File> {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const img = new Image();
|
|
|
|
|
img.onload = () => {
|
|
|
|
|
// Calculate new dimensions
|
|
|
|
|
let width = img.width;
|
|
|
|
|
let height = img.height;
|
|
|
|
|
if (width > maxWidth || height > maxHeight) {
|
|
|
|
|
const ratio = Math.min(maxWidth / width, maxHeight / height);
|
|
|
|
|
width = Math.round(width * ratio);
|
|
|
|
|
height = Math.round(height * ratio);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create canvas and draw
|
|
|
|
|
const canvas = document.createElement('canvas');
|
|
|
|
|
canvas.width = width;
|
|
|
|
|
canvas.height = height;
|
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
|
if (!ctx) {
|
|
|
|
|
reject(new Error('Canvas context not available'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
ctx.drawImage(img, 0, 0, width, height);
|
|
|
|
|
|
|
|
|
|
// Convert to blob
|
|
|
|
|
canvas.toBlob(
|
|
|
|
|
(blob) => {
|
|
|
|
|
if (!blob) {
|
|
|
|
|
reject(new Error('Failed to compress image'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const compressedFile = new File([blob], file.name.replace(/\.[^.]+$/, '.jpg'), {
|
|
|
|
|
type: 'image/jpeg',
|
|
|
|
|
});
|
|
|
|
|
resolve(compressedFile);
|
|
|
|
|
},
|
|
|
|
|
'image/jpeg',
|
|
|
|
|
quality
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
img.onerror = () => reject(new Error('Failed to load image'));
|
|
|
|
|
img.src = URL.createObjectURL(file);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function ProfileDetailPage({ locale, profile: p }: Props) {
|
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
2026-05-09 16:00:29 +08:00
|
|
|
const [displayName, setDisplayName] = useState('');
|
|
|
|
|
const [bio, setBio] = useState('');
|
2026-05-09 18:23:21 +08:00
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
|
const [uploading, setUploading] = useState(false);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
const [success, setSuccess] = useState<string | null>(null);
|
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
|
|
|
|
// Load profile on mount
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
getUserProfile()
|
|
|
|
|
.then((data) => {
|
|
|
|
|
setProfile(data);
|
|
|
|
|
setDisplayName(data.display_name || '');
|
|
|
|
|
setBio(data.bio || '');
|
|
|
|
|
})
|
|
|
|
|
.catch((err) => setError(err.message || 'Failed to load profile'))
|
|
|
|
|
.finally(() => setLoading(false));
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// Clear messages after 3 seconds
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (success) {
|
|
|
|
|
const timer = setTimeout(() => setSuccess(null), 3000);
|
|
|
|
|
return () => clearTimeout(timer);
|
|
|
|
|
}
|
|
|
|
|
}, [success]);
|
|
|
|
|
|
|
|
|
|
const handleSave = async () => {
|
|
|
|
|
setSaving(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
try {
|
|
|
|
|
await updateUserProfile({
|
|
|
|
|
display_name: displayName || undefined,
|
|
|
|
|
bio: bio || undefined,
|
|
|
|
|
});
|
|
|
|
|
// Navigate back to settings on success
|
|
|
|
|
navigate(-1);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(err instanceof Error ? err.message : 'Failed to save');
|
|
|
|
|
} finally {
|
|
|
|
|
setSaving(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleCancel = () => {
|
|
|
|
|
navigate(-1);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleAvatarClick = () => {
|
|
|
|
|
fileInputRef.current?.click();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
|
|
const file = e.target.files?.[0];
|
|
|
|
|
if (!file) return;
|
|
|
|
|
|
|
|
|
|
// Validate file type
|
|
|
|
|
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
|
|
|
|
|
setError(locale === 'en' ? 'Only PNG, JPG, WEBP allowed' : '仅支持 PNG、JPG、WEBP');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setUploading(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
try {
|
|
|
|
|
// Compress image before upload
|
|
|
|
|
const compressedFile = await compressImage(file, 512, 512, 0.8);
|
|
|
|
|
|
|
|
|
|
// Check compressed size (max 2MB after compression)
|
|
|
|
|
if (compressedFile.size > 2 * 1024 * 1024) {
|
|
|
|
|
throw new Error(locale === 'en' ? 'Image too large, please choose a smaller one' : '图片太大,请选择更小的图片');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const updated = await uploadAvatar(compressedFile);
|
|
|
|
|
setProfile(updated);
|
|
|
|
|
setSuccess(locale === 'en' ? 'Avatar updated' : '头像已更新');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(err instanceof Error ? err.message : 'Failed to upload');
|
|
|
|
|
} finally {
|
|
|
|
|
setUploading(false);
|
|
|
|
|
// Reset file input
|
|
|
|
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col gap-6 min-h-full">
|
|
|
|
|
<div className="text-slate-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 21:32:51 +08:00
|
|
|
const email = getAuth()?.user?.email || '';
|
2026-05-09 16:00:29 +08:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col lg:flex-row gap-6 min-h-full">
|
|
|
|
|
{/* Avatar edit */}
|
|
|
|
|
<div className="w-full lg:w-[360px] bg-white rounded-2xl p-7 border border-slate-200 flex flex-col items-center gap-5 shrink-0 self-start">
|
2026-05-09 18:23:21 +08:00
|
|
|
{/* Avatar preview */}
|
|
|
|
|
{profile?.avatar_url ? (
|
|
|
|
|
<img src={profile.avatar_url} alt={displayName} className="w-32 h-32 rounded-full object-cover border-2 border-violet-200" />
|
|
|
|
|
) : (
|
|
|
|
|
<div className="w-32 h-32 rounded-full bg-violet-50 border-2 border-violet-200 flex items-center justify-center">
|
2026-05-09 21:32:51 +08:00
|
|
|
<span className="text-violet-600 text-4xl font-bold">{displayName ? displayName[0].toUpperCase() : '?'}</span>
|
2026-05-09 18:23:21 +08:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-05-09 16:00:29 +08:00
|
|
|
<h3 className="text-slate-900 text-lg font-bold">{p.avatarTitle}</h3>
|
|
|
|
|
<p className="text-slate-500 text-[13px] text-center">{p.avatarHint}</p>
|
2026-05-09 18:23:21 +08:00
|
|
|
<input
|
|
|
|
|
ref={fileInputRef}
|
|
|
|
|
type="file"
|
|
|
|
|
accept="image/png,image/jpeg,image/webp"
|
|
|
|
|
onChange={handleFileChange}
|
|
|
|
|
className="hidden"
|
|
|
|
|
/>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleAvatarClick}
|
|
|
|
|
disabled={uploading}
|
|
|
|
|
className="w-full h-[42px] rounded-full bg-violet-600 text-white text-sm font-semibold hover:bg-violet-700 disabled:opacity-50 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
{uploading ? (locale === 'en' ? 'Uploading...' : '上传中...') : p.uploadBtn}
|
|
|
|
|
</button>
|
2026-05-09 16:00:29 +08:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Form */}
|
|
|
|
|
<div className="flex-1 bg-white rounded-2xl p-7 border border-slate-200 flex flex-col gap-6">
|
|
|
|
|
<h3 className="text-slate-900 text-xl font-bold">{p.formTitle}</h3>
|
|
|
|
|
|
2026-05-09 18:23:21 +08:00
|
|
|
{/* Error message */}
|
|
|
|
|
{error && (
|
|
|
|
|
<div className="text-red-500 text-sm bg-red-50 px-4 py-2 rounded-lg">{error}</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Success message */}
|
|
|
|
|
{success && (
|
|
|
|
|
<div className="text-green-600 text-sm bg-green-50 px-4 py-2 rounded-lg">{success}</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-05-09 16:00:29 +08:00
|
|
|
{/* Email readonly */}
|
|
|
|
|
<div className="bg-slate-50 rounded-xl px-4 py-4 flex items-center gap-4">
|
|
|
|
|
<span className="material-symbols-rounded text-slate-400 text-lg">email</span>
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-slate-400 text-xs">{p.emailLabel}</p>
|
2026-05-09 21:32:51 +08:00
|
|
|
<p className="text-slate-600 text-sm">{email || '-'}</p>
|
2026-05-09 16:00:29 +08:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Display name */}
|
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
<label className="text-slate-700 text-sm font-medium">{p.displayNameLabel}</label>
|
2026-05-09 18:23:21 +08:00
|
|
|
<input
|
|
|
|
|
value={displayName}
|
|
|
|
|
onChange={(e) => setDisplayName(e.target.value)}
|
|
|
|
|
placeholder={p.displayNamePlaceholder}
|
|
|
|
|
maxLength={30}
|
|
|
|
|
className="w-full h-11 px-4 rounded-lg bg-slate-50 border border-slate-200 text-sm focus:outline-none focus:border-violet-400 focus:ring-1 focus:ring-violet-400"
|
|
|
|
|
/>
|
2026-05-09 16:00:29 +08:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Bio */}
|
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
<label className="text-slate-700 text-sm font-medium">{p.bioLabel}</label>
|
2026-05-09 18:23:21 +08:00
|
|
|
<textarea
|
|
|
|
|
value={bio}
|
|
|
|
|
onChange={(e) => setBio(e.target.value)}
|
|
|
|
|
placeholder={p.bioPlaceholder}
|
|
|
|
|
rows={4}
|
|
|
|
|
maxLength={200}
|
|
|
|
|
className="w-full px-4 py-3 rounded-lg bg-slate-50 border border-slate-200 text-sm focus:outline-none focus:border-violet-400 focus:ring-1 focus:ring-violet-400 resize-none"
|
|
|
|
|
/>
|
|
|
|
|
<p className="text-slate-400 text-xs text-right">{bio.length}/200</p>
|
2026-05-09 16:00:29 +08:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex gap-3 justify-end mt-auto">
|
2026-05-09 18:23:21 +08:00
|
|
|
<button
|
|
|
|
|
onClick={handleCancel}
|
|
|
|
|
className="px-5 py-2.5 rounded-lg text-sm text-slate-500 hover:bg-slate-50 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
{p.cancelBtn}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleSave}
|
|
|
|
|
disabled={saving}
|
|
|
|
|
className="px-5 py-2.5 rounded-lg bg-violet-600 text-white text-sm font-semibold hover:bg-violet-700 disabled:opacity-50 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
{saving ? (locale === 'en' ? 'Saving...' : '保存中...') : p.saveBtn}
|
|
|
|
|
</button>
|
2026-05-09 16:00:29 +08:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|