Files
neo-blog/web/src/components/console/user-profile/index.tsx
Snowykami f501948f91 Refactor site configuration and color scheme management
- Replaced static config with dynamic site info context.
- Updated color scheme handling in various components to use site info.
- Removed deprecated config file and integrated site info fetching.
- Enhanced user preference page to allow color scheme selection.
- Adjusted blog and console components to reflect new site info structure.
- Improved error handling and fallback mechanisms for site info retrieval.
2025-09-26 00:25:34 +08:00

226 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { uploadFile } from "@/api/file";
import { updateUser } from "@/api/user";
import { ImageCropper } from "@/components/common/image-cropper";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { useAuth } from "@/contexts/auth-context";
import { getFileUri } from "@/utils/client/file";
import { getGravatarFromUser } from "@/utils/common/gravatar";
import { getFallbackAvatarFromUsername } from "@/utils/common/username";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { toast } from "sonner";
interface UploadConstraints {
allowedTypes: string[];
maxSize: number;
}
interface PictureInputChangeEvent {
target: HTMLInputElement & { files?: FileList | null };
}
export function UserProfilePage() {
const t = useTranslations("Console.user_profile")
const { user } = useAuth();
const [nickname, setNickname] = useState(user?.nickname || '')
const [username, setUsername] = useState(user?.username || '')
const [avatarFile, setAvatarFile] = useState<File | null>(null)
const [avatarFileUrl, setAvatarFileUrl] = useState<string | null>(null) // 这部分交由useEffect控制监听 avatarFile 变化
const [backgroundFile, setBackgroundFile] = useState<File | null>(null)
const [backgroundFileUrl, setBackgroundFileUrl] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [gender, setGender] = useState(user?.gender || '')
useEffect(() => {
if (!user) return;
if (!avatarFile) {
setAvatarFileUrl(getGravatarFromUser({ user }));
return;
}
const url = URL.createObjectURL(avatarFile);
setAvatarFileUrl(url);
return () => {
URL.revokeObjectURL(url);
setAvatarFileUrl(getGravatarFromUser({ user }));
};
}, [avatarFile, user]);
useEffect(() => {
if (!user) return;
if (!backgroundFile) {
setBackgroundFileUrl(null);
return;
}
const url = URL.createObjectURL(backgroundFile);
setBackgroundFileUrl(url);
return () => {
URL.revokeObjectURL(url);
setBackgroundFileUrl(null);
};
}, [backgroundFile, user]);
const handlePictureSelected = (e: PictureInputChangeEvent): void => {
const file: File | null = e.target.files?.[0] ?? null;
if (!file) {
setAvatarFile(null);
return;
}
const constraints: UploadConstraints = {
allowedTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
maxSize: 5 * 1024 * 1024, // 5 MB
};
if (!file.type || !file.type.startsWith('image/') || !constraints.allowedTypes.includes(file.type)) {
setAvatarFile(null);
toast.error(t("only_allow_picture"));
return;
}
if (file.size > constraints.maxSize) {
setAvatarFile(null);
toast.error(t("picture_size_cannot_exceed", { "size": "5MiB" }));
return;
}
setAvatarFile(file);
}
const handleBackgroundSelected = (e: PictureInputChangeEvent): void => {
const file: File | null = e.target.files?.[0] ?? null;
if (!file) {
setBackgroundFile(null);
return;
}
const constraints: UploadConstraints = {
allowedTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
maxSize: 5 * 1024 * 1024, // 5 MB
};
if (!file.type || !file.type.startsWith('image/') || !constraints.allowedTypes.includes(file.type)) {
setBackgroundFile(null);
toast.error(t("only_allow_picture"));
return;
}
if (file.size > constraints.maxSize) {
setBackgroundFile(null);
toast.error(t("picture_size_cannot_exceed", { "size": "5MiB" }));
return;
}
setBackgroundFile(file);
}
const handleSubmit = () => {
if (!user) return;
if (
nickname.trim() === '' ||
username.trim() === ''
) {
toast.error(t("nickname_and_username_cannot_be_empty"))
return
}
if (
(username.length < 1 || username.length > 20) ||
(nickname.length < 1 || nickname.length > 20)
) {
toast.error(t("nickname_and_username_must_be_between", { "min": 1, "max": 20 }))
return
}
if (
username === user.username &&
nickname === user.nickname &&
gender === user.gender &&
avatarFile === null &&
backgroundFile === null
) {
toast.warning(t("no_changes_made"))
return
}
let avatarUrl = user.avatarUrl;
let backgroundUrl = user.backgroundUrl;
setSubmitting(true);
(async () => {
if (avatarFile) {
try {
const resp = await uploadFile({ file: avatarFile });
avatarUrl = getFileUri(resp.data.id);
} catch (error: unknown) {
toast.error(`${t("failed_to_upload_avatar")}: ${error}`);
return;
}
}
if (backgroundFile) {
try {
const resp = await uploadFile({ file: backgroundFile });
backgroundUrl = getFileUri(resp.data.id);
} catch (error: unknown) {
toast.error(`${t("failed_to_upload_background")}: ${error}`);
return;
}
}
try {
await updateUser({ nickname, username, avatarUrl, backgroundUrl, gender, id: user.id });
window.location.reload();
} catch (error: unknown) {
toast.error(`${t("failed_to_update_profile")}: ${error}`);
} finally {
setSubmitting(false);
}
})();
}
const handleCropped = (blob: Blob) => {
const file = new File([blob], 'avatar.png', { type: blob.type });
setAvatarFile(file);
}
if (!user) return null
return (
<div className="grid w-full max-w-sm items-center gap-4">
<h1 className="text-2xl font-bold">
{t("public_profile")}
</h1>
<div className="grid w-full max-w-sm items-center gap-4">
<div className="grid gap-2">
<Label htmlFor="picture">{t("picture")}</Label>
<Avatar className="h-40 w-40 rounded-xl border-2">
{avatarFileUrl ?
<AvatarImage src={avatarFileUrl} alt={nickname || username} /> :
<AvatarImage src={getGravatarFromUser({ user })} alt={nickname || username} />}
<AvatarFallback>{getFallbackAvatarFromUsername(nickname || username)}</AvatarFallback>
</Avatar>
<div className="flex gap-2"><Input
id="picture"
type="file"
accept="image/png,image/jpeg,image/webp,image/gif,image/*"
onChange={handlePictureSelected}
/>
<ImageCropper image={avatarFile} onCropped={handleCropped} />
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="nickname">{t("nickname")}</Label>
<Input type="nickname" id="nickname" value={nickname} onChange={(e) => setNickname(e.target.value)} />
</div>
<div className="grid gap-2">
<Label htmlFor="username">{t("username")}</Label>
<Input type="username" id="username" value={username} onChange={(e) => setUsername(e.target.value)} />
</div >
<div className="grid gap-2">
<Label htmlFor="gender">{t("gender")}</Label>
<Input type="gender" id="gender" value={gender} onChange={(e) => setGender(e.target.value)} />
</div>
<Button className="max-w-1/3" onClick={handleSubmit} disabled={submitting}>{t("update_profile")}{submitting && '...'}</Button>
</div>
</div>
)
}