Skip to content

Commit 087886e

Browse files
committed
feat: Add profile image upload and display support
1 parent 548d5ba commit 087886e

19 files changed

Lines changed: 347 additions & 140 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"use server";
2+
3+
import { db } from "@cap/database";
4+
import { getCurrentUser } from "@cap/database/auth/session";
5+
import { users } from "@cap/database/schema";
6+
import { eq } from "drizzle-orm";
7+
import { revalidatePath } from "next/cache";
8+
9+
export async function removeProfileImage() {
10+
const user = await getCurrentUser();
11+
12+
if (!user) {
13+
throw new Error("Unauthorized");
14+
}
15+
16+
await db()
17+
.update(users)
18+
.set({ image: null })
19+
.where(eq(users.id, user.id));
20+
21+
revalidatePath("/dashboard/settings/account");
22+
revalidatePath("/dashboard", "layout");
23+
24+
return { success: true } as const;
25+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"use server";
2+
3+
import { db } from "@cap/database";
4+
import { getCurrentUser } from "@cap/database/auth/session";
5+
import { users } from "@cap/database/schema";
6+
import { serverEnv } from "@cap/env";
7+
import { S3Buckets } from "@cap/web-backend";
8+
import { eq } from "drizzle-orm";
9+
import { Effect, Option } from "effect";
10+
import { revalidatePath } from "next/cache";
11+
import { sanitizeFile } from "@/lib/sanitizeFile";
12+
import { runPromise } from "@/lib/server";
13+
import { randomUUID } from "node:crypto";
14+
15+
const MAX_FILE_SIZE_BYTES = 3 * 1024 * 1024; // 3MB
16+
const ALLOWED_IMAGE_TYPES = new Map<string, string>([
17+
["image/png", "png"],
18+
["image/jpeg", "jpg"],
19+
["image/jpg", "jpg"],
20+
]);
21+
22+
export async function uploadProfileImage(formData: FormData) {
23+
const user = await getCurrentUser();
24+
25+
if (!user) {
26+
throw new Error("Unauthorized");
27+
}
28+
29+
const file = formData.get("image") as File | null;
30+
31+
if (!file) {
32+
throw new Error("No file provided");
33+
}
34+
35+
const normalizedType = file.type.toLowerCase();
36+
const fileExtension = ALLOWED_IMAGE_TYPES.get(normalizedType);
37+
38+
if (!fileExtension) {
39+
throw new Error("Only PNG or JPEG images are supported");
40+
}
41+
42+
if (file.size > MAX_FILE_SIZE_BYTES) {
43+
throw new Error("File size must be 3MB or less");
44+
}
45+
46+
const fileKey = `users/${user.id}/profile-${Date.now()}-${randomUUID()}.${fileExtension}`;
47+
48+
try {
49+
const sanitizedFile = await sanitizeFile(file);
50+
let imageUrl: string | undefined;
51+
52+
await Effect.gen(function* () {
53+
const [bucket] = yield* S3Buckets.getBucketAccess(Option.none());
54+
55+
const bodyBytes = yield* Effect.promise(async () => {
56+
const buf = await sanitizedFile.arrayBuffer();
57+
return new Uint8Array(buf);
58+
});
59+
60+
yield* bucket.putObject(fileKey, bodyBytes, {
61+
contentType: file.type,
62+
});
63+
64+
if (serverEnv().CAP_AWS_BUCKET_URL) {
65+
imageUrl = `${serverEnv().CAP_AWS_BUCKET_URL}/${fileKey}`;
66+
} else if (serverEnv().CAP_AWS_ENDPOINT) {
67+
imageUrl = `${serverEnv().CAP_AWS_ENDPOINT}/${bucket.bucketName}/${fileKey}`;
68+
} else {
69+
imageUrl = `https://${bucket.bucketName}.s3.${
70+
serverEnv().CAP_AWS_REGION || "us-east-1"
71+
}.amazonaws.com/${fileKey}`;
72+
}
73+
}).pipe(runPromise);
74+
75+
if (typeof imageUrl !== "string" || imageUrl.length === 0) {
76+
throw new Error("Failed to resolve uploaded profile image URL");
77+
}
78+
79+
const finalImageUrl = imageUrl;
80+
81+
await db()
82+
.update(users)
83+
.set({ image: finalImageUrl })
84+
.where(eq(users.id, user.id));
85+
86+
revalidatePath("/dashboard/settings/account");
87+
revalidatePath("/dashboard", "layout");
88+
89+
return { success: true, imageUrl: finalImageUrl } as const;
90+
} catch (error) {
91+
console.error("Error uploading profile image:", error);
92+
throw new Error(error instanceof Error ? error.message : "Upload failed");
93+
}
94+
}

apps/web/actions/videos/new-comment.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export async function newComment(data: {
6767
const commentWithAuthor = {
6868
...newComment,
6969
authorName: user.name,
70+
authorImage: user.image ?? null,
7071
sending: false,
7172
};
7273

apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -269,21 +269,12 @@ const User = () => {
269269
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"
270270
>
271271
<div className="flex items-center">
272-
{user.image ? (
273-
<Image
274-
src={user.image}
275-
alt={user.name ?? "User"}
276-
width={24}
277-
height={24}
278-
className="rounded-full"
279-
/>
280-
) : (
281-
<Avatar
282-
letterClass="text-xs lg:text-md"
283-
name={user.name ?? "User"}
284-
className="size-[24px] text-gray-12"
285-
/>
286-
)}
272+
<Avatar
273+
letterClass="text-xs lg:text-md"
274+
name={user.name ?? "User"}
275+
imageUrl={user.image ?? undefined}
276+
className="flex-shrink-0 size-[24px] text-gray-12"
277+
/>
287278
<span className="ml-2 text-sm truncate lg:ml-2 lg:text-md text-gray-12">
288279
{user.name ?? "User"}
289280
</span>

apps/web/app/(org)/dashboard/settings/account/Settings.tsx

Lines changed: 114 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,13 @@ import {
1212
import { Organisation } from "@cap/web-domain";
1313
import { useMutation } from "@tanstack/react-query";
1414
import { useRouter } from "next/navigation";
15-
import { useEffect, useState } from "react";
15+
import { useEffect, useId, useState } from "react";
1616
import { toast } from "sonner";
1717
import { useDashboardContext } from "../../Contexts";
1818
import { patchAccountSettings } from "./server";
19+
import { uploadProfileImage } from "@/actions/account/upload-profile-image";
20+
import { removeProfileImage } from "@/actions/account/remove-profile-image";
21+
import { FileInput } from "@/components/FileInput";
1922

2023
export const Settings = ({
2124
user,
@@ -29,6 +32,24 @@ export const Settings = ({
2932
const [defaultOrgId, setDefaultOrgId] = useState<
3033
Organisation.OrganisationId | undefined
3134
>(user?.defaultOrgId || undefined);
35+
const avatarInputId = useId();
36+
const initialProfileImage = user?.image ?? null;
37+
const [profileImageOverride, setProfileImageOverride] = useState<
38+
string | null | undefined
39+
>(undefined);
40+
const profileImagePreviewUrl =
41+
profileImageOverride !== undefined
42+
? profileImageOverride
43+
: initialProfileImage;
44+
45+
useEffect(() => {
46+
if (
47+
profileImageOverride !== undefined &&
48+
profileImageOverride === initialProfileImage
49+
) {
50+
setProfileImageOverride(undefined);
51+
}
52+
}, [initialProfileImage, profileImageOverride]);
3253

3354
// Track if form has unsaved changes
3455
const hasChanges =
@@ -66,15 +87,103 @@ export const Settings = ({
6687
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
6788
}, [hasChanges]);
6889

90+
const {
91+
mutate: uploadProfileImageMutation,
92+
isPending: isUploadingProfileImage,
93+
} = useMutation({
94+
mutationFn: async (file: File) => {
95+
const formData = new FormData();
96+
formData.append("image", file);
97+
return uploadProfileImage(formData);
98+
},
99+
onSuccess: (result) => {
100+
if (result.success) {
101+
setProfileImageOverride(result.imageUrl ?? null);
102+
toast.success("Profile image updated successfully");
103+
router.refresh();
104+
}
105+
},
106+
onError: (error) => {
107+
console.error("Error uploading profile image:", error);
108+
setProfileImageOverride(initialProfileImage);
109+
toast.error(
110+
error instanceof Error
111+
? error.message
112+
: "Failed to upload profile image",
113+
);
114+
},
115+
});
116+
117+
const {
118+
mutate: removeProfileImageMutation,
119+
isPending: isRemovingProfileImage,
120+
} = useMutation({
121+
mutationFn: removeProfileImage,
122+
onSuccess: (result) => {
123+
if (result.success) {
124+
setProfileImageOverride(null);
125+
toast.success("Profile image removed");
126+
router.refresh();
127+
}
128+
},
129+
onError: (error) => {
130+
console.error("Error removing profile image:", error);
131+
setProfileImageOverride(initialProfileImage);
132+
toast.error(
133+
error instanceof Error
134+
? error.message
135+
: "Failed to remove profile image",
136+
);
137+
},
138+
});
139+
140+
const isProfileImageMutating = isUploadingProfileImage || isRemovingProfileImage;
141+
142+
const handleProfileImageChange = (file: File | null) => {
143+
if (!file || isProfileImageMutating) {
144+
return;
145+
}
146+
setProfileImageOverride(undefined);
147+
uploadProfileImageMutation(file);
148+
};
149+
150+
const handleProfileImageRemove = () => {
151+
if (isProfileImageMutating) {
152+
return;
153+
}
154+
setProfileImageOverride(null);
155+
removeProfileImageMutation();
156+
};
157+
69158
return (
70159
<form
71160
onSubmit={(e) => {
72161
e.preventDefault();
73162
updateName();
74163
}}
75164
>
76-
<div className="flex flex-col flex-wrap gap-6 w-full md:flex-row">
77-
<Card className="flex-1 space-y-1">
165+
<div className="grid gap-6 w-full md:grid-cols-2">
166+
<Card className="flex flex-col gap-4">
167+
<div className="space-y-1">
168+
<CardTitle>Profile image</CardTitle>
169+
<CardDescription>
170+
This image appears in your profile, comments, and shared
171+
caps.
172+
</CardDescription>
173+
</div>
174+
<FileInput
175+
id={avatarInputId}
176+
name="profileImage"
177+
height={120}
178+
previewIconSize={28}
179+
initialPreviewUrl={profileImagePreviewUrl}
180+
onChange={handleProfileImageChange}
181+
onRemove={handleProfileImageRemove}
182+
disabled={isProfileImageMutating}
183+
isLoading={isProfileImageMutating}
184+
/>
185+
</Card>
186+
<Card className="space-y-1">
78187
<CardTitle>Your name</CardTitle>
79188
<CardDescription>
80189
Changing your name below will update how your name appears when
@@ -103,7 +212,7 @@ export const Settings = ({
103212
</div>
104213
</div>
105214
</Card>
106-
<Card className="flex flex-col flex-1 gap-4 justify-between items-stretch">
215+
<Card className="flex flex-col gap-4">
107216
<div className="space-y-1">
108217
<CardTitle>Contact email address</CardTitle>
109218
<CardDescription>
@@ -118,7 +227,7 @@ export const Settings = ({
118227
disabled
119228
/>
120229
</Card>
121-
<Card className="flex flex-col flex-1 gap-4 justify-between items-stretch">
230+
<Card className="flex flex-col gap-4">
122231
<div className="space-y-1">
123232
<CardTitle>Default organization</CardTitle>
124233
<CardDescription>This is the default organization</CardDescription>

apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MemberSelect.tsx

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
1212
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
1313
import clsx from "clsx";
1414
import { ChevronDown } from "lucide-react";
15-
import Image from "next/image";
1615
import { forwardRef, useEffect, useRef, useState } from "react";
1716
import { useDashboardContext } from "../../../Contexts";
1817

@@ -196,17 +195,12 @@ export const MemberSelect = forwardRef<HTMLDivElement, MemberSelectProps>(
196195
key={opt.value}
197196
className="flex gap-2 items-center justify-start p-1.5 text-[13px] rounded-xl cursor-pointer"
198197
>
199-
{opt.image ? (
200-
<Image
201-
src={opt.image}
202-
alt={opt.label}
203-
width={20}
204-
height={20}
205-
className="w-5 h-5 rounded-full"
206-
/>
207-
) : (
208-
<Avatar name={opt.label} className="w-5 h-5" />
209-
)}
198+
<Avatar
199+
name={opt.label}
200+
imageUrl={opt.image}
201+
className="w-5 h-5"
202+
letterClass="text-[11px]"
203+
/>
210204
{opt.label}
211205
</DropdownMenuItem>
212206
))}
@@ -224,17 +218,12 @@ export const MemberSelect = forwardRef<HTMLDivElement, MemberSelectProps>(
224218
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"
225219
>
226220
<div className="flex gap-2 items-center">
227-
{tag.image ? (
228-
<Image
229-
src={tag.image}
230-
alt={tag.label}
231-
width={20}
232-
height={20}
233-
className="w-5 h-5 rounded-full"
234-
/>
235-
) : (
236-
<Avatar name={tag.label} className="w-5 h-5" />
237-
)}
221+
<Avatar
222+
name={tag.label}
223+
imageUrl={tag.image}
224+
className="w-5 h-5"
225+
letterClass="text-[11px]"
226+
/>
238227
<p className="truncate text-[13px] text-gray-12">
239228
{tag.label}
240229
</p>

0 commit comments

Comments
 (0)