Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/web/actions/account/remove-profile-image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"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;
}
94 changes: 94 additions & 0 deletions apps/web/actions/account/upload-profile-image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"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";
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";

const MAX_FILE_SIZE_BYTES = 3 * 1024 * 1024; // 3MB
const ALLOWED_IMAGE_TYPES = new Map<string, string>([
["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");
}
}
1 change: 1 addition & 0 deletions apps/web/actions/videos/new-comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export async function newComment(data: {
const commentWithAuthor = {
...newComment,
authorName: user.name,
authorImage: user.image ?? null,
sending: false,
};

Expand Down
21 changes: 6 additions & 15 deletions apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<div className="flex items-center">
{user.image ? (
<Image
src={user.image}
alt={user.name ?? "User"}
width={24}
height={24}
className="rounded-full"
/>
) : (
<Avatar
letterClass="text-xs lg:text-md"
name={user.name ?? "User"}
className="size-[24px] text-gray-12"
/>
)}
<Avatar
letterClass="text-xs lg:text-md"
name={user.name ?? "User"}
imageUrl={user.image ?? undefined}
className="flex-shrink-0 size-[24px] text-gray-12"
/>
<span className="ml-2 text-sm truncate lg:ml-2 lg:text-md text-gray-12">
{user.name ?? "User"}
</span>
Expand Down
118 changes: 113 additions & 5 deletions apps/web/app/(org)/dashboard/settings/account/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ 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 { 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";

Expand All @@ -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 =
Expand Down Expand Up @@ -66,15 +87,102 @@ 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(undefined);
toast.success("Profile image updated successfully");
router.refresh();
}
},
onError: (error) => {
console.error("Error uploading profile image:", error);
setProfileImageOverride(undefined);
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;
}
uploadProfileImageMutation(file);
};

const handleProfileImageRemove = () => {
if (isProfileImageMutating) {
return;
}
setProfileImageOverride(null);
removeProfileImageMutation();
};

return (
<form
onSubmit={(e) => {
e.preventDefault();
updateName();
}}
>
<div className="flex flex-col flex-wrap gap-6 w-full md:flex-row">
<Card className="flex-1 space-y-1">
<div className="grid gap-6 w-full md:grid-cols-2">
<Card className="flex flex-col gap-4">
<div className="space-y-1">
<CardTitle>Profile image</CardTitle>
<CardDescription>
This image appears in your profile, comments, and shared caps.
</CardDescription>
</div>
<FileInput
id={avatarInputId}
name="profileImage"
height={120}
previewIconSize={28}
initialPreviewUrl={profileImagePreviewUrl}
onChange={handleProfileImageChange}
onRemove={handleProfileImageRemove}
disabled={isProfileImageMutating}
isLoading={isProfileImageMutating}
/>
</Card>
<Card className="space-y-1">
<CardTitle>Your name</CardTitle>
<CardDescription>
Changing your name below will update how your name appears when
Expand Down Expand Up @@ -103,7 +211,7 @@ export const Settings = ({
</div>
</div>
</Card>
<Card className="flex flex-col flex-1 gap-4 justify-between items-stretch">
<Card className="flex flex-col gap-4">
<div className="space-y-1">
<CardTitle>Contact email address</CardTitle>
<CardDescription>
Expand All @@ -118,7 +226,7 @@ export const Settings = ({
disabled
/>
</Card>
<Card className="flex flex-col flex-1 gap-4 justify-between items-stretch">
<Card className="flex flex-col gap-4">
<div className="space-y-1">
<CardTitle>Default organization</CardTitle>
<CardDescription>This is the default organization</CardDescription>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -196,17 +195,12 @@ export const MemberSelect = forwardRef<HTMLDivElement, MemberSelectProps>(
key={opt.value}
className="flex gap-2 items-center justify-start p-1.5 text-[13px] rounded-xl cursor-pointer"
>
{opt.image ? (
<Image
src={opt.image}
alt={opt.label}
width={20}
height={20}
className="w-5 h-5 rounded-full"
/>
) : (
<Avatar name={opt.label} className="w-5 h-5" />
)}
<Avatar
name={opt.label}
imageUrl={opt.image}
className="w-5 h-5"
letterClass="text-[11px]"
/>
{opt.label}
</DropdownMenuItem>
))}
Expand All @@ -224,17 +218,12 @@ export const MemberSelect = forwardRef<HTMLDivElement, MemberSelectProps>(
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"
>
<div className="flex gap-2 items-center">
{tag.image ? (
<Image
src={tag.image}
alt={tag.label}
width={20}
height={20}
className="w-5 h-5 rounded-full"
/>
) : (
<Avatar name={tag.label} className="w-5 h-5" />
)}
<Avatar
name={tag.label}
imageUrl={tag.image}
className="w-5 h-5"
letterClass="text-[11px]"
/>
<p className="truncate text-[13px] text-gray-12">
{tag.label}
</p>
Expand Down
Loading