feat(web): add settings sub-pages and connect to backend APIs
- Add GeneralSettingsPage for language, privacy, and notification settings - Add FeedbackPage for user feedback submission with image upload - Connect settings to backend PATCH /users/me/settings API - Implement language preference sync between frontend and backend - Update login flow to pass language preference and redirect based on user settings - Add Astro entry pages for /settings/general and /settings/feedback routes - Update sidebar navigation: language button links to general settings - Fix account data card to link to profile page - Remove "deletion" text from account data description
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
locale: string;
|
||||
@@ -6,52 +9,246 @@ interface Props {
|
||||
profile: { avatarTitle: string; avatarHint: string; uploadBtn: string; formTitle: string; emailLabel: string; displayNameLabel: string; displayNamePlaceholder: string; bioLabel: string; bioPlaceholder: string; saveBtn: string; cancelBtn: string };
|
||||
}
|
||||
|
||||
export default function ProfileDetailPage({ profile: p }: Props) {
|
||||
// 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);
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [bio, setBio] = useState('');
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const email = getAuth()?.user?.email || 'user@example.com';
|
||||
|
||||
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">
|
||||
<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">U</span>
|
||||
</div>
|
||||
{/* 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 || 'U')[0].toUpperCase()}</span>
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-slate-900 text-lg font-bold">{p.avatarTitle}</h3>
|
||||
<p className="text-slate-500 text-[13px] text-center">{p.avatarHint}</p>
|
||||
<button className="w-full h-[42px] rounded-full bg-violet-600 text-white text-sm font-semibold hover:bg-violet-700 transition-colors">{p.uploadBtn}</button>
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 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">user@example.com</p>
|
||||
<p className="text-slate-600 text-sm">{email}</p>
|
||||
</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}
|
||||
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" />
|
||||
<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"
|
||||
/>
|
||||
</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}
|
||||
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" />
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end mt-auto">
|
||||
<button className="px-5 py-2.5 rounded-lg text-sm text-slate-500 hover:bg-slate-50 transition-colors">{p.cancelBtn}</button>
|
||||
<button className="px-5 py-2.5 rounded-lg bg-violet-600 text-white text-sm font-semibold hover:bg-violet-700 transition-colors">{p.saveBtn}</button>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user