mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-26 11:06:23 +00:00
- 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.
226 lines
7.5 KiB
TypeScript
226 lines
7.5 KiB
TypeScript
"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>
|
||
)
|
||
} |