232 lines
6.6 KiB
TypeScript
232 lines
6.6 KiB
TypeScript
// =====================================================
|
||
// 頭像上傳組件
|
||
// =====================================================
|
||
|
||
'use client';
|
||
|
||
import { useState, useRef, useEffect } from 'react';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Label } from '@/components/ui/label';
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||
import { Upload, X, Check, AlertCircle } from 'lucide-react';
|
||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||
import { isValidImageType, isValidImageSize } from '@/lib/image-utils';
|
||
|
||
interface AvatarUploadProps {
|
||
userId: string;
|
||
currentAvatar?: string;
|
||
userName?: string;
|
||
onUploadSuccess?: (imageUrl: string) => void;
|
||
onUploadError?: (error: string) => void;
|
||
}
|
||
|
||
export function AvatarUpload({
|
||
userId,
|
||
currentAvatar,
|
||
userName,
|
||
onUploadSuccess,
|
||
onUploadError
|
||
}: AvatarUploadProps) {
|
||
const [isUploading, setIsUploading] = useState(false);
|
||
const [uploadStatus, setUploadStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||
const [errorMessage, setErrorMessage] = useState('');
|
||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
// 監聽 currentAvatar 變化,清除預覽
|
||
useEffect(() => {
|
||
if (currentAvatar) {
|
||
setPreviewUrl(null);
|
||
}
|
||
}, [currentAvatar]);
|
||
|
||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = event.target.files?.[0];
|
||
if (!file) return;
|
||
|
||
// 驗證文件類型
|
||
if (!isValidImageType(file)) {
|
||
setErrorMessage('只支援 JPEG、PNG、WebP 格式的圖片');
|
||
setUploadStatus('error');
|
||
return;
|
||
}
|
||
|
||
// 驗證文件大小(限制 5MB)
|
||
if (!isValidImageSize(file, 5)) {
|
||
setErrorMessage('圖片大小不能超過 5MB');
|
||
setUploadStatus('error');
|
||
return;
|
||
}
|
||
|
||
// 創建預覽 URL
|
||
const url = URL.createObjectURL(file);
|
||
setPreviewUrl(url);
|
||
setUploadStatus('idle');
|
||
setErrorMessage('');
|
||
};
|
||
|
||
const handleUpload = async () => {
|
||
const file = fileInputRef.current?.files?.[0];
|
||
if (!file) return;
|
||
|
||
setIsUploading(true);
|
||
setUploadStatus('idle');
|
||
setErrorMessage('');
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('avatar', file);
|
||
formData.append('userId', userId);
|
||
|
||
const response = await fetch('/api/upload/avatar', {
|
||
method: 'POST',
|
||
body: formData,
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
setUploadStatus('success');
|
||
setPreviewUrl(null); // 清除預覽,讓組件顯示實際頭像
|
||
onUploadSuccess?.(result.data.imageUrl);
|
||
console.log('頭像上傳成功:', result.data);
|
||
} else {
|
||
setUploadStatus('error');
|
||
setErrorMessage(result.error || '上傳失敗');
|
||
onUploadError?.(result.error || '上傳失敗');
|
||
}
|
||
} catch (error) {
|
||
setUploadStatus('error');
|
||
const errorMsg = error instanceof Error ? error.message : '上傳失敗';
|
||
setErrorMessage(errorMsg);
|
||
onUploadError?.(errorMsg);
|
||
} finally {
|
||
setIsUploading(false);
|
||
}
|
||
};
|
||
|
||
const handleRemoveFile = () => {
|
||
if (fileInputRef.current) {
|
||
fileInputRef.current.value = '';
|
||
}
|
||
setPreviewUrl(null);
|
||
setUploadStatus('idle');
|
||
setErrorMessage('');
|
||
};
|
||
|
||
const getDisplayAvatar = () => {
|
||
if (previewUrl) return previewUrl;
|
||
if (currentAvatar && currentAvatar !== '') return currentAvatar;
|
||
return null;
|
||
};
|
||
|
||
const getInitials = () => {
|
||
if (userName) {
|
||
return userName.split(' ').map(n => n[0]).join('').toUpperCase();
|
||
}
|
||
return 'U';
|
||
};
|
||
|
||
return (
|
||
<Card className="w-full max-w-md">
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<Upload className="h-5 w-5" />
|
||
頭像上傳
|
||
</CardTitle>
|
||
<CardDescription>
|
||
支援 JPEG、PNG、WebP 格式,最大 5MB
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{/* 頭像預覽 */}
|
||
<div className="flex justify-center">
|
||
<Avatar className="h-24 w-24">
|
||
<AvatarImage src={getDisplayAvatar() || ''} alt="頭像" />
|
||
<AvatarFallback className="text-lg">
|
||
{getInitials()}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
</div>
|
||
|
||
{/* 文件選擇 */}
|
||
<div className="space-y-2">
|
||
<Label htmlFor="avatar">選擇圖片</Label>
|
||
<Input
|
||
id="avatar"
|
||
type="file"
|
||
accept="image/jpeg,image/jpg,image/png,image/webp"
|
||
onChange={handleFileSelect}
|
||
ref={fileInputRef}
|
||
disabled={isUploading}
|
||
/>
|
||
</div>
|
||
|
||
{/* 預覽和操作按鈕 */}
|
||
{previewUrl && (
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm text-muted-foreground">預覽</span>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={handleRemoveFile}
|
||
disabled={isUploading}
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
<div className="flex justify-center">
|
||
<img
|
||
src={previewUrl}
|
||
alt="預覽"
|
||
className="h-16 w-16 rounded-full object-cover border"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 上傳按鈕 */}
|
||
<Button
|
||
onClick={handleUpload}
|
||
disabled={!fileInputRef.current?.files?.[0] || isUploading}
|
||
className="w-full"
|
||
>
|
||
{isUploading ? (
|
||
<>
|
||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
|
||
上傳中...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Upload className="h-4 w-4 mr-2" />
|
||
上傳頭像
|
||
</>
|
||
)}
|
||
</Button>
|
||
|
||
{/* 狀態提示 */}
|
||
{uploadStatus === 'success' && (
|
||
<Alert>
|
||
<Check className="h-4 w-4" />
|
||
<AlertDescription>
|
||
頭像上傳成功!
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{uploadStatus === 'error' && (
|
||
<Alert variant="destructive">
|
||
<AlertCircle className="h-4 w-4" />
|
||
<AlertDescription>
|
||
{errorMessage}
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|