feat(profile): add avatar reset functionality and clean up profile form
This commit is contained in:
@@ -8,7 +8,7 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { DEFAULT_THEME_ID } from "@/lib/constants";
|
import { DEFAULT_THEME_ID } from "@/lib/constants";
|
||||||
import { requireCurrentUser } from "@/lib/session";
|
import { requireCurrentUser } from "@/lib/session";
|
||||||
import { normalizeUsername, safeUrl, sanitizeOptionalPlainText, sanitizePlainText } from "@/lib/sanitize";
|
import { normalizeUsername, sanitizeOptionalPlainText, sanitizePlainText } from "@/lib/sanitize";
|
||||||
import { profileSchema } from "@/lib/validators/profile";
|
import { profileSchema } from "@/lib/validators/profile";
|
||||||
|
|
||||||
export type ActionResult = {
|
export type ActionResult = {
|
||||||
@@ -81,12 +81,7 @@ export async function updateProfileAction(formData: FormData): Promise<ActionRes
|
|||||||
const currentUsername = user.profile?.username;
|
const currentUsername = user.profile?.username;
|
||||||
const existingAvatarUrl = user.profile?.avatarUrl ?? null;
|
const existingAvatarUrl = user.profile?.avatarUrl ?? null;
|
||||||
const avatarFile = formData.get("avatarFile");
|
const avatarFile = formData.get("avatarFile");
|
||||||
const rawAvatarUrl = String(formData.get("avatarUrl") ?? "");
|
let nextAvatarUrl = existingAvatarUrl ?? "";
|
||||||
const sanitizedAvatarUrl = rawAvatarUrl.startsWith(AVATAR_WEB_PREFIX)
|
|
||||||
? rawAvatarUrl
|
|
||||||
: (safeUrl(rawAvatarUrl) ?? "");
|
|
||||||
|
|
||||||
let nextAvatarUrl = sanitizedAvatarUrl;
|
|
||||||
let uploadedAvatarUrl: string | null = null;
|
let uploadedAvatarUrl: string | null = null;
|
||||||
|
|
||||||
if (avatarFile instanceof File && avatarFile.size > 0) {
|
if (avatarFile instanceof File && avatarFile.size > 0) {
|
||||||
@@ -165,3 +160,36 @@ export async function updateProfileAction(formData: FormData): Promise<ActionRes
|
|||||||
return { success: false, message: "Could not update profile" };
|
return { success: false, message: "Could not update profile" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resetAvatarAction(): Promise<ActionResult> {
|
||||||
|
const user = await requireCurrentUser();
|
||||||
|
const currentUsername = user.profile?.username;
|
||||||
|
const existingAvatarUrl = user.profile?.avatarUrl ?? null;
|
||||||
|
|
||||||
|
if (!user.profile) {
|
||||||
|
return { success: false, message: "Profile not found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingAvatarUrl) {
|
||||||
|
return { success: true, message: "Avatar is already empty" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.profile.update({
|
||||||
|
where: { userId: user.id },
|
||||||
|
data: { avatarUrl: null }
|
||||||
|
});
|
||||||
|
|
||||||
|
await deleteOldUploadedAvatar(existingAvatarUrl);
|
||||||
|
|
||||||
|
if (currentUsername) {
|
||||||
|
revalidatePath(`/u/${currentUsername}`);
|
||||||
|
}
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
revalidatePath("/dashboard/profile");
|
||||||
|
|
||||||
|
return { success: true, message: "Avatar reset" };
|
||||||
|
} catch {
|
||||||
|
return { success: false, message: "Could not reset avatar" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export default async function DashboardProfilePage() {
|
|||||||
username: profile?.username ?? "",
|
username: profile?.username ?? "",
|
||||||
displayName: profile?.displayName ?? "",
|
displayName: profile?.displayName ?? "",
|
||||||
bio: profile?.bio ?? "",
|
bio: profile?.bio ?? "",
|
||||||
avatarUrl: profile?.avatarUrl ?? "",
|
|
||||||
themeId: profile?.themeId ?? DEFAULT_THEME_ID,
|
themeId: profile?.themeId ?? DEFAULT_THEME_ID,
|
||||||
isPublic: profile?.isPublic ?? true
|
isPublic: profile?.isPublic ?? true
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FormEvent, useState, useTransition } from "react";
|
import { FormEvent, useState, useTransition } from "react";
|
||||||
import { updateProfileAction } from "@/actions/profile";
|
import { resetAvatarAction, updateProfileAction } from "@/actions/profile";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select } from "@/components/ui/select";
|
import { Select } from "@/components/ui/select";
|
||||||
@@ -18,7 +18,6 @@ type ProfileFormProps = {
|
|||||||
username: string;
|
username: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
bio: string;
|
bio: string;
|
||||||
avatarUrl: string;
|
|
||||||
themeId: string;
|
themeId: string;
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
};
|
};
|
||||||
@@ -33,11 +32,23 @@ export function ProfileForm({ initialValues, themes }: ProfileFormProps) {
|
|||||||
const [username, setUsername] = useState(initialValues.username);
|
const [username, setUsername] = useState(initialValues.username);
|
||||||
const [displayName, setDisplayName] = useState(initialValues.displayName);
|
const [displayName, setDisplayName] = useState(initialValues.displayName);
|
||||||
const [bio, setBio] = useState(initialValues.bio);
|
const [bio, setBio] = useState(initialValues.bio);
|
||||||
const [avatarUrl, setAvatarUrl] = useState(initialValues.avatarUrl);
|
|
||||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||||
const [themeId, setThemeId] = useState(initialValues.themeId);
|
const [themeId, setThemeId] = useState(initialValues.themeId);
|
||||||
const [isPublic, setIsPublic] = useState(initialValues.isPublic);
|
const [isPublic, setIsPublic] = useState(initialValues.isPublic);
|
||||||
|
|
||||||
|
function handleResetAvatar() {
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await resetAvatarAction();
|
||||||
|
setMessage(result.message);
|
||||||
|
setIsError(!result.success);
|
||||||
|
if (result.success) {
|
||||||
|
setAvatarFile(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
@@ -46,7 +57,6 @@ export function ProfileForm({ initialValues, themes }: ProfileFormProps) {
|
|||||||
formData.set("username", username);
|
formData.set("username", username);
|
||||||
formData.set("displayName", displayName);
|
formData.set("displayName", displayName);
|
||||||
formData.set("bio", bio);
|
formData.set("bio", bio);
|
||||||
formData.set("avatarUrl", avatarUrl);
|
|
||||||
if (avatarFile) {
|
if (avatarFile) {
|
||||||
formData.set("avatarFile", avatarFile);
|
formData.set("avatarFile", avatarFile);
|
||||||
}
|
}
|
||||||
@@ -103,16 +113,17 @@ export function ProfileForm({ initialValues, themes }: ProfileFormProps) {
|
|||||||
onChange={(event) => setAvatarFile(event.target.files?.[0] ?? null)}
|
onChange={(event) => setAvatarFile(event.target.files?.[0] ?? null)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<div style={{ marginTop: 16, marginBottom: 20 }}>
|
||||||
<label className="space-y-2 text-sm">
|
<Button
|
||||||
<span className="form-label">Avatar URL</span>
|
type="button"
|
||||||
<Input
|
variant="secondary"
|
||||||
type="url"
|
className="dashboard-action min-h-11 px-5"
|
||||||
placeholder="https://..."
|
disabled={isPending}
|
||||||
value={avatarUrl}
|
onClick={handleResetAvatar}
|
||||||
onChange={(event) => setAvatarUrl(event.target.value)}
|
>
|
||||||
/>
|
Reset avatar
|
||||||
</label>
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="space-y-2 text-sm">
|
<label className="space-y-2 text-sm">
|
||||||
<span className="form-label">Theme</span>
|
<span className="form-label">Theme</span>
|
||||||
|
|||||||
@@ -17,18 +17,8 @@ export const profileSchema = z.object({
|
|||||||
if (!value) {
|
if (!value) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
return value.startsWith("/uploads/avatars/");
|
||||||
if (value.startsWith("/uploads/avatars/")) {
|
}, "Avatar must be an uploaded file path")
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = new URL(value);
|
|
||||||
return url.protocol === "http:" || url.protocol === "https:";
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, "Avatar must be a valid URL or uploaded file path")
|
|
||||||
.optional()
|
.optional()
|
||||||
.or(z.literal("")),
|
.or(z.literal("")),
|
||||||
themeId: z.string().trim().min(1),
|
themeId: z.string().trim().min(1),
|
||||||
|
|||||||
Reference in New Issue
Block a user