feat: enhance post management with pagination, search, and order functionality
All checks were successful
Push to Helm Chart Repository / build (push) Successful in 11s

- Added search input for filtering posts by keywords.
- Implemented pagination controls for navigating through posts.
- Introduced order selector for sorting posts based on various criteria.
- Enhanced post item display with additional metrics (view count, like count, comment count).
- Added dropdown menu for post actions (edit, view, toggle privacy, delete).
- Integrated double confirmation for delete action.
- Updated user profile to support background image upload.
- Improved user security settings with better layout and validation.
- Refactored auth context to use useCallback for logout function.
- Added command palette component for improved command execution.
- Introduced popover component for better UI interactions.
- Implemented debounce hooks for optimized state updates.
- Updated localization files with new keys for improved internationalization.
- Added tailwind configuration for styling.
This commit is contained in:
2025-09-25 00:51:29 +08:00
parent 59b68613cd
commit 64b1c54911
44 changed files with 2790 additions and 474 deletions

View File

@ -32,9 +32,11 @@ export function UserProfilePage() {
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;
@ -50,6 +52,20 @@ export function UserProfilePage() {
};
}, [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) {
@ -67,12 +83,35 @@ export function UserProfilePage() {
}
if (file.size > constraints.maxSize) {
setAvatarFile(null);
toast.error(t("picture_size_cannot_exceed", {"size": "5MiB"}));
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 (
@ -87,7 +126,7 @@ export function UserProfilePage() {
(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}))
toast.error(t("nickname_and_username_must_be_between", { "min": 1, "max": 20 }))
return
}
@ -95,13 +134,15 @@ export function UserProfilePage() {
username === user.username &&
nickname === user.nickname &&
gender === user.gender &&
avatarFile === null
avatarFile === null &&
backgroundFile === null
) {
toast.warning(t("no_changes_made"))
return
}
let avatarUrl = user.avatarUrl;
let backgroundUrl = user.backgroundUrl;
setSubmitting(true);
(async () => {
if (avatarFile) {
@ -114,8 +155,18 @@ export function UserProfilePage() {
}
}
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, gender, id: user.id });
await updateUser({ nickname, username, avatarUrl, backgroundUrl, gender, id: user.id });
window.location.reload();
} catch (error: unknown) {
toast.error(`${t("failed_to_update_profile")}: ${error}`);
@ -123,7 +174,7 @@ export function UserProfilePage() {
setSubmitting(false);
}
})();
}
const handleCropped = (blob: Blob) => {
@ -139,28 +190,40 @@ export function UserProfilePage() {
{t("public_profile")}
</h1>
<Separator className="my-2" />
<div className="grid w-full max-w-sm items-center gap-3">
<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-3"><Input
id="picture"
type="file"
accept="image/png,image/jpeg,image/webp,image/gif,image/*"
onChange={handlePictureSelected}
/>
<ImageCropper image={avatarFile} onCropped={handleCropped} />
<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>
<Label htmlFor="nickname">{t("nickname")}</Label>
<Input type="nickname" id="nickname" value={nickname} onChange={(e) => setNickname(e.target.value)} />
<Label htmlFor="username">{t("username")}</Label>
<Input type="username" id="username" value={username} onChange={(e) => setUsername(e.target.value)} />
<Label htmlFor="gender">{t("gender")}</Label>
<Input type="gender" id="gender" value={gender} onChange={(e) => setGender(e.target.value)}/>
<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>