From 087886e944aea3a1639aa1124ae76e97430cfd37 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 17 Oct 2025 01:22:39 +0100 Subject: [PATCH 1/3] feat: Add profile image upload and display support --- .../actions/account/remove-profile-image.ts | 25 ++++ .../actions/account/upload-profile-image.ts | 94 ++++++++++++++ apps/web/actions/videos/new-comment.ts | 1 + .../dashboard/_components/Navbar/Top.tsx | 21 +--- .../dashboard/settings/account/Settings.tsx | 119 +++++++++++++++++- .../[spaceId]/components/MemberSelect.tsx | 35 ++---- .../[spaceId]/components/MembersIndicator.tsx | 21 +--- apps/web/app/s/[videoId]/Share.tsx | 2 + .../[videoId]/_components/CapVideoPlayer.tsx | 1 + .../s/[videoId]/_components/CommentStamp.tsx | 14 ++- .../s/[videoId]/_components/ShareHeader.tsx | 19 +-- .../s/[videoId]/_components/ShareVideo.tsx | 1 + .../app/s/[videoId]/_components/Sidebar.tsx | 1 + .../app/s/[videoId]/_components/Toolbar.tsx | 4 +- .../_components/tabs/Activity/Comment.tsx | 1 + .../_components/tabs/Activity/Comments.tsx | 2 + apps/web/app/s/[videoId]/page.tsx | 1 + apps/web/components/FileInput.tsx | 111 ++++++++-------- packages/ui/src/components/Avatar.tsx | 14 +++ 19 files changed, 347 insertions(+), 140 deletions(-) create mode 100644 apps/web/actions/account/remove-profile-image.ts create mode 100644 apps/web/actions/account/upload-profile-image.ts diff --git a/apps/web/actions/account/remove-profile-image.ts b/apps/web/actions/account/remove-profile-image.ts new file mode 100644 index 0000000000..83cdebb8fb --- /dev/null +++ b/apps/web/actions/account/remove-profile-image.ts @@ -0,0 +1,25 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { users } from "@cap/database/schema"; +import { eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; + +export async function removeProfileImage() { + const user = await getCurrentUser(); + + if (!user) { + throw new Error("Unauthorized"); + } + + await db() + .update(users) + .set({ image: null }) + .where(eq(users.id, user.id)); + + revalidatePath("/dashboard/settings/account"); + revalidatePath("/dashboard", "layout"); + + return { success: true } as const; +} diff --git a/apps/web/actions/account/upload-profile-image.ts b/apps/web/actions/account/upload-profile-image.ts new file mode 100644 index 0000000000..8f54d5cc03 --- /dev/null +++ b/apps/web/actions/account/upload-profile-image.ts @@ -0,0 +1,94 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { users } from "@cap/database/schema"; +import { serverEnv } from "@cap/env"; +import { S3Buckets } from "@cap/web-backend"; +import { eq } from "drizzle-orm"; +import { Effect, Option } from "effect"; +import { revalidatePath } from "next/cache"; +import { sanitizeFile } from "@/lib/sanitizeFile"; +import { runPromise } from "@/lib/server"; +import { randomUUID } from "node:crypto"; + +const MAX_FILE_SIZE_BYTES = 3 * 1024 * 1024; // 3MB +const ALLOWED_IMAGE_TYPES = new Map([ + ["image/png", "png"], + ["image/jpeg", "jpg"], + ["image/jpg", "jpg"], +]); + +export async function uploadProfileImage(formData: FormData) { + const user = await getCurrentUser(); + + if (!user) { + throw new Error("Unauthorized"); + } + + const file = formData.get("image") as File | null; + + if (!file) { + throw new Error("No file provided"); + } + + const normalizedType = file.type.toLowerCase(); + const fileExtension = ALLOWED_IMAGE_TYPES.get(normalizedType); + + if (!fileExtension) { + throw new Error("Only PNG or JPEG images are supported"); + } + + if (file.size > MAX_FILE_SIZE_BYTES) { + throw new Error("File size must be 3MB or less"); + } + + const fileKey = `users/${user.id}/profile-${Date.now()}-${randomUUID()}.${fileExtension}`; + + try { + const sanitizedFile = await sanitizeFile(file); + let imageUrl: string | undefined; + + await Effect.gen(function* () { + const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); + + const bodyBytes = yield* Effect.promise(async () => { + const buf = await sanitizedFile.arrayBuffer(); + return new Uint8Array(buf); + }); + + yield* bucket.putObject(fileKey, bodyBytes, { + contentType: file.type, + }); + + if (serverEnv().CAP_AWS_BUCKET_URL) { + imageUrl = `${serverEnv().CAP_AWS_BUCKET_URL}/${fileKey}`; + } else if (serverEnv().CAP_AWS_ENDPOINT) { + imageUrl = `${serverEnv().CAP_AWS_ENDPOINT}/${bucket.bucketName}/${fileKey}`; + } else { + imageUrl = `https://${bucket.bucketName}.s3.${ + serverEnv().CAP_AWS_REGION || "us-east-1" + }.amazonaws.com/${fileKey}`; + } + }).pipe(runPromise); + + if (typeof imageUrl !== "string" || imageUrl.length === 0) { + throw new Error("Failed to resolve uploaded profile image URL"); + } + + const finalImageUrl = imageUrl; + + await db() + .update(users) + .set({ image: finalImageUrl }) + .where(eq(users.id, user.id)); + + revalidatePath("/dashboard/settings/account"); + revalidatePath("/dashboard", "layout"); + + return { success: true, imageUrl: finalImageUrl } as const; + } catch (error) { + console.error("Error uploading profile image:", error); + throw new Error(error instanceof Error ? error.message : "Upload failed"); + } +} diff --git a/apps/web/actions/videos/new-comment.ts b/apps/web/actions/videos/new-comment.ts index f12729c3dd..9f83404a9e 100644 --- a/apps/web/actions/videos/new-comment.ts +++ b/apps/web/actions/videos/new-comment.ts @@ -67,6 +67,7 @@ export async function newComment(data: { const commentWithAuthor = { ...newComment, authorName: user.name, + authorImage: user.image ?? null, sending: false, }; diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx index 96393b26fa..59bf7b4f7a 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx @@ -269,21 +269,12 @@ const User = () => { className="flex gap-2 justify-between items-center p-2 rounded-xl border data-[state=open]:border-gray-3 data-[state=open]:bg-gray-3 border-transparent transition-colors cursor-pointer group lg:gap-6 hover:border-gray-3" >
- {user.image ? ( - {user.name - ) : ( - - )} + {user.name ?? "User"} diff --git a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx index 6c8dc3ade8..c46bc6f851 100644 --- a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx +++ b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx @@ -12,10 +12,13 @@ import { import { Organisation } from "@cap/web-domain"; import { useMutation } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect, useId, useState } from "react"; import { toast } from "sonner"; import { useDashboardContext } from "../../Contexts"; import { patchAccountSettings } from "./server"; +import { uploadProfileImage } from "@/actions/account/upload-profile-image"; +import { removeProfileImage } from "@/actions/account/remove-profile-image"; +import { FileInput } from "@/components/FileInput"; export const Settings = ({ user, @@ -29,6 +32,24 @@ export const Settings = ({ const [defaultOrgId, setDefaultOrgId] = useState< Organisation.OrganisationId | undefined >(user?.defaultOrgId || undefined); + const avatarInputId = useId(); + const initialProfileImage = user?.image ?? null; + const [profileImageOverride, setProfileImageOverride] = useState< + string | null | undefined + >(undefined); + const profileImagePreviewUrl = + profileImageOverride !== undefined + ? profileImageOverride + : initialProfileImage; + + useEffect(() => { + if ( + profileImageOverride !== undefined && + profileImageOverride === initialProfileImage + ) { + setProfileImageOverride(undefined); + } + }, [initialProfileImage, profileImageOverride]); // Track if form has unsaved changes const hasChanges = @@ -66,6 +87,74 @@ export const Settings = ({ return () => window.removeEventListener("beforeunload", handleBeforeUnload); }, [hasChanges]); + const { + mutate: uploadProfileImageMutation, + isPending: isUploadingProfileImage, + } = useMutation({ + mutationFn: async (file: File) => { + const formData = new FormData(); + formData.append("image", file); + return uploadProfileImage(formData); + }, + onSuccess: (result) => { + if (result.success) { + setProfileImageOverride(result.imageUrl ?? null); + toast.success("Profile image updated successfully"); + router.refresh(); + } + }, + onError: (error) => { + console.error("Error uploading profile image:", error); + setProfileImageOverride(initialProfileImage); + toast.error( + error instanceof Error + ? error.message + : "Failed to upload profile image", + ); + }, + }); + + const { + mutate: removeProfileImageMutation, + isPending: isRemovingProfileImage, + } = useMutation({ + mutationFn: removeProfileImage, + onSuccess: (result) => { + if (result.success) { + setProfileImageOverride(null); + toast.success("Profile image removed"); + router.refresh(); + } + }, + onError: (error) => { + console.error("Error removing profile image:", error); + setProfileImageOverride(initialProfileImage); + toast.error( + error instanceof Error + ? error.message + : "Failed to remove profile image", + ); + }, + }); + + const isProfileImageMutating = isUploadingProfileImage || isRemovingProfileImage; + + const handleProfileImageChange = (file: File | null) => { + if (!file || isProfileImageMutating) { + return; + } + setProfileImageOverride(undefined); + uploadProfileImageMutation(file); + }; + + const handleProfileImageRemove = () => { + if (isProfileImageMutating) { + return; + } + setProfileImageOverride(null); + removeProfileImageMutation(); + }; + return (
{ @@ -73,8 +162,28 @@ export const Settings = ({ updateName(); }} > -
- +
+ +
+ Profile image + + This image appears in your profile, comments, and shared + caps. + +
+ +
+ Your name Changing your name below will update how your name appears when @@ -103,7 +212,7 @@ export const Settings = ({
- +
Contact email address @@ -118,7 +227,7 @@ export const Settings = ({ disabled /> - +
Default organization This is the default organization diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MemberSelect.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MemberSelect.tsx index 8a08735d28..3ef262d732 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MemberSelect.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MemberSelect.tsx @@ -12,7 +12,6 @@ import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import clsx from "clsx"; import { ChevronDown } from "lucide-react"; -import Image from "next/image"; import { forwardRef, useEffect, useRef, useState } from "react"; import { useDashboardContext } from "../../../Contexts"; @@ -196,17 +195,12 @@ export const MemberSelect = forwardRef( key={opt.value} className="flex gap-2 items-center justify-start p-1.5 text-[13px] rounded-xl cursor-pointer" > - {opt.image ? ( - {opt.label} - ) : ( - - )} + {opt.label} ))} @@ -224,17 +218,12 @@ export const MemberSelect = forwardRef( className="flex gap-4 items-center hover:scale-[1.02] transition-transform h-full px-2 py-1.5 min-h-full text-xs rounded-xl bg-gray-3 text-gray-11 wobble" >
- {tag.image ? ( - {tag.label} - ) : ( - - )} +

{tag.label}

diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersIndicator.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersIndicator.tsx index f5681a20cc..81bf454abd 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersIndicator.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersIndicator.tsx @@ -17,7 +17,6 @@ import { type Space, User } from "@cap/web-domain"; import { faPlus, faUserGroup } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; -import Image from "next/image"; import { useCallback, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -166,20 +165,12 @@ export const MembersIndicator = ({ key={member.userId} className="flex gap-2 items-center p-3 rounded-lg border bg-gray-3 border-gray-4" > - {member.image ? ( - {member.name - ) : ( - - )} + {member.name || member.email} diff --git a/apps/web/app/s/[videoId]/Share.tsx b/apps/web/app/s/[videoId]/Share.tsx index fb28603fc0..63dd9a922f 100644 --- a/apps/web/app/s/[videoId]/Share.tsx +++ b/apps/web/app/s/[videoId]/Share.tsx @@ -25,10 +25,12 @@ import { Toolbar } from "./_components/Toolbar"; type CommentWithAuthor = typeof commentsSchema.$inferSelect & { authorName: string | null; + authorImage: string | null; }; export type CommentType = typeof commentsSchema.$inferSelect & { authorName?: string | null; + authorImage?: string | null; sending?: boolean; }; diff --git a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx index 732bcba60e..a346cc8fcd 100644 --- a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx +++ b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx @@ -50,6 +50,7 @@ interface Props { type: "text" | "emoji"; content: string; authorName?: string | null; + authorImage?: string | null; }>; onSeek?: (time: number) => void; } diff --git a/apps/web/app/s/[videoId]/_components/CommentStamp.tsx b/apps/web/app/s/[videoId]/_components/CommentStamp.tsx index 8492839f56..76ff9ea603 100644 --- a/apps/web/app/s/[videoId]/_components/CommentStamp.tsx +++ b/apps/web/app/s/[videoId]/_components/CommentStamp.tsx @@ -9,6 +9,7 @@ interface CommentStampsProps { type: "text" | "emoji"; content: string; authorName?: string | null; + authorImage?: string | null; }; adjustedPosition: string; handleMouseEnter: (id: string) => void; @@ -60,12 +61,13 @@ const CommentStamp: React.FC = ({
- {/* User avatar/initial */} - + {/* User avatar/initial */} + {/* Comment content */}
diff --git a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx index 50989fb70b..87e0675f5a 100644 --- a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx @@ -9,7 +9,6 @@ import { faChevronDown, faLock } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Check, Copy, Globe2 } from "lucide-react"; import moment from "moment"; -import Image from "next/image"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { toast } from "sonner"; @@ -238,18 +237,12 @@ export const ShareHeader = ({
- {data.ownerImage ? ( - {data.ownerName - ) : ( - - )} +

{data.ownerName}

diff --git a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx index 0b8a6ef6c6..6f006c6581 100644 --- a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx @@ -29,6 +29,7 @@ declare global { type CommentWithAuthor = typeof commentsSchema.$inferSelect & { authorName: string | null; + authorImage: string | null; }; export const ShareVideo = forwardRef< diff --git a/apps/web/app/s/[videoId]/_components/Sidebar.tsx b/apps/web/app/s/[videoId]/_components/Sidebar.tsx index b3e15dac6c..ab4eb24729 100644 --- a/apps/web/app/s/[videoId]/_components/Sidebar.tsx +++ b/apps/web/app/s/[videoId]/_components/Sidebar.tsx @@ -15,6 +15,7 @@ type TabType = "activity" | "transcript" | "summary" | "settings"; type CommentType = typeof commentsSchema.$inferSelect & { authorName?: string | null; + authorImage?: string | null; }; type VideoWithOrganizationInfo = typeof videos.$inferSelect & { diff --git a/apps/web/app/s/[videoId]/_components/Toolbar.tsx b/apps/web/app/s/[videoId]/_components/Toolbar.tsx index 45eb245f26..9fe6d31255 100644 --- a/apps/web/app/s/[videoId]/_components/Toolbar.tsx +++ b/apps/web/app/s/[videoId]/_components/Toolbar.tsx @@ -40,6 +40,7 @@ export const Toolbar = ({ id: Comment.CommentId.make(`temp-${Date.now()}`), authorId: User.UserId.make(user?.id || "anonymous"), authorName: user?.name || "Anonymous", + authorImage: user?.image ?? null, content: emoji, createdAt: new Date(), videoId: data.id, @@ -80,7 +81,8 @@ export const Toolbar = ({ const optimisticComment: CommentType = { id: Comment.CommentId.make(`temp-${Date.now()}`), authorId: User.UserId.make(user?.id || "anonymous"), - authorName: Comment.CommentId.make(user?.name || "Anonymous"), + authorName: user?.name || "Anonymous", + authorImage: user?.image ?? null, content: comment, createdAt: new Date(), videoId: data.id, diff --git a/apps/web/app/s/[videoId]/_components/tabs/Activity/Comment.tsx b/apps/web/app/s/[videoId]/_components/tabs/Activity/Comment.tsx index e7210cf3c8..e856f48e58 100644 --- a/apps/web/app/s/[videoId]/_components/tabs/Activity/Comment.tsx +++ b/apps/web/app/s/[videoId]/_components/tabs/Activity/Comment.tsx @@ -78,6 +78,7 @@ const CommentComponent: React.FC<{ className="size-6" letterClass="text-sm" name={comment.authorName} + imageUrl={comment.authorImage ?? undefined} /> void; disabled?: boolean; @@ -43,7 +50,6 @@ export const FileInput: React.FC = ({ }) => { const fileInputRef = useRef(null); const [isDragging, setIsDragging] = useState(false); - const [selectedFile, setSelectedFile] = useState(null); const [previewUrl, setPreviewUrl] = useState( initialPreviewUrl, ); @@ -117,24 +123,19 @@ export const FileInput: React.FC = ({ const handleFileChange = () => { const file = fileInputRef.current?.files?.[0]; if (file) { - // Validate file type - only allow jpg, jpeg, svg, and png - const allowedTypes = [ - "image/jpeg", - "image/jpg", - "image/png", - "image/svg+xml", - ]; - if (!allowedTypes.includes(file.type)) { - toast.error("Please select a JPG, JPEG, PNG, or SVG file"); + // Validate file type - only allow jpg, jpeg, and png + const normalizedType = file.type.toLowerCase(); + if (!ALLOWED_IMAGE_TYPES.has(normalizedType)) { + toast.error("Please select a PNG or JPEG image"); if (fileInputRef.current) { fileInputRef.current.value = ""; - } + } return; } - // Validate file size (limit to 2MB) - if (file.size > 2 * 1024 * 1024) { - toast.error("File size must be less than 2MB"); + // Validate file size (limit to 3MB) + if (file.size > MAX_FILE_SIZE_BYTES) { + toast.error("File size must be 3MB or less"); if (fileInputRef.current) { fileInputRef.current.value = ""; } @@ -149,7 +150,6 @@ export const FileInput: React.FC = ({ // Create a new preview URL for immediate feedback const newPreviewUrl = URL.createObjectURL(file); setPreviewUrl(newPreviewUrl); - setSelectedFile(null); // Call the onChange callback if (onChange) { @@ -167,7 +167,6 @@ export const FileInput: React.FC = ({ } setPreviewUrl(null); - setSelectedFile(null); if (fileInputRef.current) { fileInputRef.current.value = ""; @@ -185,51 +184,39 @@ export const FileInput: React.FC = ({ }; return ( -

+
- {" "} {/* Fixed height container to prevent resizing */} - {selectedFile || previewUrl ? ( -
-
-
- {selectedFile ? ( - <> -

- {selectedFile.name} -

-

- {(selectedFile.size / 1024).toFixed(1)} KB -

- - ) : ( -
-

- Current icon:{" "} -

-
- {previewUrl && ( - File preview - )} -
+ {previewUrl ? ( +
+
+
+
+

+ Current icon:{" "} +

+
+ {previewUrl && ( + File preview + )}
- )} +
@@ -255,25 +242,25 @@ export const FileInput: React.FC = ({ onDragLeave={handleDragLeave} onDrop={handleDrop} className={clsx( - "flex gap-3 justify-center items-center px-4 w-full rounded-xl border border-dashed transition-all duration-300 cursor-pointer h-full", + "flex h-full w-full cursor-pointer items-center justify-center gap-3 rounded-xl border border-dashed px-4 transition-all duration-300", isDragging ? "border-blue-500 bg-gray-5" - : "hover:bg-gray-2 border-gray-5 " + notDraggingClassName, - isLoading || disabled ? "opacity-50 pointer-events-none" : "", + : `border-gray-5 hover:bg-gray-2 ${notDraggingClassName}`, + isLoading || disabled ? "pointer-events-none opacity-50" : "", )} > {isLoading ? ( ) : ( )} -

+

{isLoading ? "Uploading..." : "Choose a file or drag & drop it here"} @@ -287,7 +274,7 @@ export const FileInput: React.FC = ({ ref={fileInputRef} id={id} disabled={disabled || isLoading} - accept="image/jpeg, image/jpg, image/png, image/svg+xml" + accept={ACCEPTED_IMAGE_TYPES} onChange={handleFileChange} name={name} /> diff --git a/packages/ui/src/components/Avatar.tsx b/packages/ui/src/components/Avatar.tsx index 40b352f5b7..2bac164503 100644 --- a/packages/ui/src/components/Avatar.tsx +++ b/packages/ui/src/components/Avatar.tsx @@ -64,15 +64,29 @@ const avatarTextPalette = [ export interface AvatarProps { name: string | null | undefined; + imageUrl?: string | null; className?: string; letterClass?: string; } export const Avatar: React.FC = ({ name, + imageUrl, className = "", letterClass = "text-xs", }) => { + if (imageUrl) { + return ( + {name + ); + } + const initial = name?.[0]?.toUpperCase() || "A"; const charCode = initial.charCodeAt(0); const isAlpha = charCode >= 65 && charCode <= 90; From 5f94a4d6e88088f3079d88da6cf31b27d9f66796 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 17 Oct 2025 01:57:20 +0100 Subject: [PATCH 2/3] formatting --- apps/web/actions/account/remove-profile-image.ts | 5 +---- apps/web/actions/account/upload-profile-image.ts | 2 +- .../(org)/dashboard/settings/account/Settings.tsx | 12 ++++++------ 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/apps/web/actions/account/remove-profile-image.ts b/apps/web/actions/account/remove-profile-image.ts index 83cdebb8fb..268bf7b023 100644 --- a/apps/web/actions/account/remove-profile-image.ts +++ b/apps/web/actions/account/remove-profile-image.ts @@ -13,10 +13,7 @@ export async function removeProfileImage() { throw new Error("Unauthorized"); } - await db() - .update(users) - .set({ image: null }) - .where(eq(users.id, user.id)); + await db().update(users).set({ image: null }).where(eq(users.id, user.id)); revalidatePath("/dashboard/settings/account"); revalidatePath("/dashboard", "layout"); diff --git a/apps/web/actions/account/upload-profile-image.ts b/apps/web/actions/account/upload-profile-image.ts index 8f54d5cc03..f445937ead 100644 --- a/apps/web/actions/account/upload-profile-image.ts +++ b/apps/web/actions/account/upload-profile-image.ts @@ -1,5 +1,6 @@ "use server"; +import { randomUUID } from "node:crypto"; import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { users } from "@cap/database/schema"; @@ -10,7 +11,6 @@ import { Effect, Option } from "effect"; import { revalidatePath } from "next/cache"; import { sanitizeFile } from "@/lib/sanitizeFile"; import { runPromise } from "@/lib/server"; -import { randomUUID } from "node:crypto"; const MAX_FILE_SIZE_BYTES = 3 * 1024 * 1024; // 3MB const ALLOWED_IMAGE_TYPES = new Map([ diff --git a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx index c46bc6f851..dfc731eb61 100644 --- a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx +++ b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx @@ -14,11 +14,11 @@ import { useMutation } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { useEffect, useId, useState } from "react"; import { toast } from "sonner"; -import { useDashboardContext } from "../../Contexts"; -import { patchAccountSettings } from "./server"; -import { uploadProfileImage } from "@/actions/account/upload-profile-image"; import { removeProfileImage } from "@/actions/account/remove-profile-image"; +import { uploadProfileImage } from "@/actions/account/upload-profile-image"; import { FileInput } from "@/components/FileInput"; +import { useDashboardContext } from "../../Contexts"; +import { patchAccountSettings } from "./server"; export const Settings = ({ user, @@ -137,7 +137,8 @@ export const Settings = ({ }, }); - const isProfileImageMutating = isUploadingProfileImage || isRemovingProfileImage; + const isProfileImageMutating = + isUploadingProfileImage || isRemovingProfileImage; const handleProfileImageChange = (file: File | null) => { if (!file || isProfileImageMutating) { @@ -167,8 +168,7 @@ export const Settings = ({

Profile image - This image appears in your profile, comments, and shared - caps. + This image appears in your profile, comments, and shared caps.
Date: Fri, 17 Oct 2025 02:00:26 +0100 Subject: [PATCH 3/3] Refactor profile image handling and fix formatting --- .../(org)/dashboard/settings/account/Settings.tsx | 5 ++--- .../app/s/[videoId]/_components/CommentStamp.tsx | 14 +++++++------- apps/web/components/FileInput.tsx | 7 ++----- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx index dfc731eb61..463e58a1a0 100644 --- a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx +++ b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx @@ -98,14 +98,14 @@ export const Settings = ({ }, onSuccess: (result) => { if (result.success) { - setProfileImageOverride(result.imageUrl ?? null); + setProfileImageOverride(undefined); toast.success("Profile image updated successfully"); router.refresh(); } }, onError: (error) => { console.error("Error uploading profile image:", error); - setProfileImageOverride(initialProfileImage); + setProfileImageOverride(undefined); toast.error( error instanceof Error ? error.message @@ -144,7 +144,6 @@ export const Settings = ({ if (!file || isProfileImageMutating) { return; } - setProfileImageOverride(undefined); uploadProfileImageMutation(file); }; diff --git a/apps/web/app/s/[videoId]/_components/CommentStamp.tsx b/apps/web/app/s/[videoId]/_components/CommentStamp.tsx index 76ff9ea603..b4feeabd64 100644 --- a/apps/web/app/s/[videoId]/_components/CommentStamp.tsx +++ b/apps/web/app/s/[videoId]/_components/CommentStamp.tsx @@ -61,13 +61,13 @@ const CommentStamp: React.FC = ({
- {/* User avatar/initial */} - + {/* User avatar/initial */} + {/* Comment content */}
diff --git a/apps/web/components/FileInput.tsx b/apps/web/components/FileInput.tsx index 5f768889fb..cf0f4e8610 100644 --- a/apps/web/components/FileInput.tsx +++ b/apps/web/components/FileInput.tsx @@ -14,10 +14,7 @@ import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { Tooltip } from "./Tooltip"; -const ALLOWED_IMAGE_TYPES = new Set([ - "image/jpeg", - "image/png", -]); +const ALLOWED_IMAGE_TYPES = new Set(["image/jpeg", "image/png"]); const MAX_FILE_SIZE_BYTES = 3 * 1024 * 1024; const ACCEPTED_IMAGE_TYPES = Array.from(ALLOWED_IMAGE_TYPES).join(","); @@ -129,7 +126,7 @@ export const FileInput: React.FC = ({ toast.error("Please select a PNG or JPEG image"); if (fileInputRef.current) { fileInputRef.current.value = ""; - } + } return; }