Files
eryao/web/src/components/ProfileDetailPage.tsx
T

253 lines
9.4 KiB
TypeScript
Raw Normal View History

import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { getAuth } from '../lib/auth';
2026-05-10 20:01:14 +08:00
import { updateProfileResource, uploadAvatarResource, useProfile } from '../lib/resources';
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 };
}
// 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();
2026-05-10 20:01:14 +08:00
const profileState = useProfile();
const profile = profileState.data ?? null;
2026-05-09 16:00:29 +08:00
const [displayName, setDisplayName] = useState('');
const [bio, setBio] = useState('');
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);
useEffect(() => {
2026-05-10 20:01:14 +08:00
if (!profileState.data) return;
setDisplayName(profileState.data.display_name || '');
setBio(profileState.data.bio || '');
}, [profileState.data]);
useEffect(() => {
if (profileState.error instanceof Error) setError(profileState.error.message || 'Failed to load profile');
}, [profileState.error]);
// 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 {
2026-05-10 20:01:14 +08:00
await updateProfileResource({
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' : '图片太大,请选择更小的图片');
}
2026-05-10 20:01:14 +08:00
await uploadAvatarResource(compressedFile);
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 = '';
}
};
2026-05-10 20:01:14 +08:00
if (profileState.loading) {
return (
<div className="flex flex-col gap-6 min-h-full">
<div className="text-slate-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
</div>
);
}
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">
{/* 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">
<span className="text-violet-600 text-4xl font-bold">{displayName ? displayName[0].toUpperCase() : '?'}</span>
</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>
<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>
{/* 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>
<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>
<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>
<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">
<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>
);
}