Skip to content

Commit

Permalink
✨ Add support for uploading profile pictures (#1332)
Browse files Browse the repository at this point in the history
  • Loading branch information
lukevella authored Sep 8, 2024
1 parent cf32e0d commit 32ba10b
Show file tree
Hide file tree
Showing 24 changed files with 1,615 additions and 65 deletions.
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
},
"dependencies": {
"@auth/prisma-adapter": "^1.0.3",
"@aws-sdk/client-s3": "^3.645.0",
"@aws-sdk/s3-request-presigner": "^3.645.0",
"@hookform/resolvers": "^3.3.1",
"@next/bundle-analyzer": "^12.3.4",
"@radix-ui/react-slot": "^1.0.1",
Expand Down
11 changes: 10 additions & 1 deletion apps/web/public/locales/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -273,5 +273,14 @@
"timeZoneChangeDetectorMessage": "Your timezone has changed to <b>{currentTimeZone}</b>. Do you want to update your preferences?",
"yesUpdateTimezone": "Yes, update my timezone",
"noKeepCurrentTimezone": "No, keep the current timezone",
"annualBenefit": "{count} months free"
"annualBenefit": "{count} months free",
"removeAvatar": "Remove",
"featureNotAvailable": "Feature not available",
"featureNotAvailableDescription": "This feature requires object storage to be enabled.",
"uploadProfilePicture": "Upload",
"profilePictureDescription": "Up to 2MB, JPG or PNG",
"invalidFileType": "Invalid file type",
"invalidFileTypeDescription": "Please upload a JPG or PNG file.",
"fileTooLarge": "File too large",
"fileTooLargeDescription": "Please upload a file smaller than 2MB."
}
2 changes: 1 addition & 1 deletion apps/web/src/app/[locale]/(admin)/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ export function Sidebar() {
>
<Link href="/settings/profile">
<div>
<CurrentUserAvatar />
<CurrentUserAvatar size={40} />
</div>
<span className="ml-1 grid grow">
<span className="font-semibold">{user.name}</span>
Expand Down
59 changes: 59 additions & 0 deletions apps/web/src/app/api/storage/[...key]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { GetObjectCommand } from "@aws-sdk/client-s3";
import * as Sentry from "@sentry/nextjs";
import { NextRequest, NextResponse } from "next/server";

import { env } from "@/env";
import { getS3Client } from "@/utils/s3";

async function getAvatar(key: string) {
const s3Client = getS3Client();

if (!s3Client) {
throw new Error("S3 client not initialized");
}

const command = new GetObjectCommand({
Bucket: env.S3_BUCKET_NAME,
Key: key,
});

const response = await s3Client.send(command);

if (!response.Body) {
throw new Error("Object not found");
}

const arrayBuffer = await response.Body.transformToByteArray();
const buffer = Buffer.from(arrayBuffer);

return {
buffer,
contentType: response.ContentType || "application/octet-stream",
};
}

export async function GET(
_req: NextRequest,
context: { params: { key: string[] } },
) {
const imageKey = context.params.key.join("/");
if (!imageKey) {
return new Response("No key provided", { status: 400 });
}

try {
const { buffer, contentType } = await getAvatar(imageKey);
return new NextResponse(buffer, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=3600",
},
});
} catch (error) {
Sentry.captureException(error);
return NextResponse.json(
{ error: "Failed to fetch object" },
{ status: 500 },
);
}
}
36 changes: 31 additions & 5 deletions apps/web/src/components/current-user-avatar.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,40 @@
"use client";
import { Avatar, AvatarFallback, AvatarImage } from "@rallly/ui/avatar";
import { Avatar, AvatarFallback } from "@rallly/ui/avatar";
import Image from "next/image";

import { useUser } from "@/components/user-provider";

export const CurrentUserAvatar = ({ className }: { className?: string }) => {
function getAvatarUrl(imageKey: string) {
// Some users have avatars that come from external providers (e.g. Google).
if (imageKey.startsWith("https://")) {
return imageKey;
}

return `/api/storage/${imageKey}`;
}

export const CurrentUserAvatar = ({
size,
className,
}: {
size: number;
className?: string;
}) => {
const { user } = useUser();
return (
<Avatar className={className}>
<AvatarImage src={user.image ?? undefined} />
<AvatarFallback>{user.name[0]}</AvatarFallback>
<Avatar className={className} style={{ width: size, height: size }}>
{user.image ? (
<Image
src={getAvatarUrl(user.image)}
width={128}
height={128}
alt={user.name}
style={{ objectFit: "cover" }}
objectFit="cover"
/>
) : (
<AvatarFallback>{user.name[0]}</AvatarFallback>
)}
</Avatar>
);
};
10 changes: 3 additions & 7 deletions apps/web/src/components/settings/language-preference.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,14 @@ import { Form, FormField, FormItem, FormLabel } from "@rallly/ui/form";
import { ArrowUpRight } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { useTranslation } from "next-i18next";
import { useForm } from "react-hook-form";
import { z } from "zod";

import { LanguageSelect } from "@/components/poll/language-selector";
import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider";
import { trpc } from "@/utils/trpc/client";
import { usePreferences } from "@/contexts/preferences";

const formSchema = z.object({
language: z.string(),
Expand All @@ -28,18 +27,15 @@ export const LanguagePreference = () => {
language: i18n.language,
},
});

const updatePreferences = trpc.user.updatePreferences.useMutation();
const session = useSession();
const { updatePreferences } = usePreferences();

return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(async (data) => {
if (!user.isGuest) {
await updatePreferences.mutateAsync({ locale: data.language });
await updatePreferences({ locale: data.language });
}
await session.update({ locale: data.language });
router.refresh();
})}
>
Expand Down
183 changes: 180 additions & 3 deletions apps/web/src/components/settings/profile-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,166 @@ import {
FormItem,
FormLabel,
} from "@rallly/ui/form";
import { useToast } from "@rallly/ui/hooks/use-toast";
import { Input } from "@rallly/ui/input";
import * as Sentry from "@sentry/nextjs";
import { useState } from "react";
import { useForm } from "react-hook-form";

import { useTranslation } from "@/app/i18n/client";
import { CurrentUserAvatar } from "@/components/current-user-avatar";
import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider";
import { usePostHog } from "@/utils/posthog";
import { trpc } from "@/utils/trpc/client";

const allowedMimeTypes = ["image/jpeg", "image/png"];

function ChangeAvatarButton({
onSuccess,
}: {
onSuccess: (imageKey: string) => void;
}) {
const { t } = useTranslation();
const { toast } = useToast();
const [isUploading, setIsUploading] = useState(false);
const getAvatarUploadUrlMutation = trpc.user.getAvatarUploadUrl.useMutation();
const updateAvatarMutation = trpc.user.updateAvatar.useMutation({
onSuccess: (_res, input) => {
onSuccess(input.imageKey);
},
});

const handleFileChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];

if (!file) return;

const fileType = file.type;

if (!allowedMimeTypes.includes(fileType)) {
toast({
title: t("invalidFileType", {
defaultValue: "Invalid file type",
}),
description: t("invalidFileTypeDescription", {
defaultValue: "Please upload a JPG or PNG file.",
}),
});
Sentry.captureMessage("Invalid file type", {
level: "info",
extra: {
fileType,
},
});
return;
}

if (file.size > 2 * 1024 * 1024) {
toast({
title: t("fileTooLarge", {
defaultValue: "File too large",
}),
description: t("fileTooLargeDescription", {
defaultValue: "Please upload a file smaller than 2MB.",
}),
});
Sentry.captureMessage("File too large", {
level: "info",
extra: {
fileSize: file.size,
},
});
return;
}
setIsUploading(true);
try {
// Get pre-signed URL
const res = await getAvatarUploadUrlMutation.mutateAsync({
fileType,
fileSize: file.size,
});

if (!res.success) {
if (res.cause === "object-storage-not-enabled") {
toast({
title: t("featureNotAvailable", {
defaultValue: "Feature not available",
}),
description: t("featureNotAvailableDescription", {
defaultValue:
"This feature requires object storage to be enabled.",
}),
});
return;
}
}

const { url, fields } = res;

await fetch(url, {
method: "PUT",
body: file,
headers: {
"Content-Type": fileType,
"Content-Length": file.size.toString(),
},
});

await updateAvatarMutation.mutateAsync({
imageKey: fields.key,
});
} catch (error) {
console.error(error);
} finally {
setIsUploading(false);
}
};

return (
<>
<Button
loading={isUploading}
onClick={() => {
document.getElementById("avatar-upload")?.click();
}}
>
<Trans i18nKey="uploadProfilePicture" defaults="Upload" />
</Button>
<input
id="avatar-upload"
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
</>
);
}

function RemoveAvatarButton({ onSuccess }: { onSuccess?: () => void }) {
const { refresh } = useUser();
const removeAvatarMutation = trpc.user.removeAvatar.useMutation({
onSuccess: () => {
refresh({ image: null });
onSuccess?.();
},
});

return (
<Button
loading={removeAvatarMutation.isLoading}
variant="ghost"
onClick={() => {
removeAvatarMutation.mutate();
}}
>
<Trans i18nKey="removeAvatar" defaults="Remove" />
</Button>
);
}

export const ProfileSettings = () => {
const { user, refresh } = useUser();
Expand All @@ -27,7 +181,7 @@ export const ProfileSettings = () => {
});

const { control, handleSubmit, formState, reset } = form;

const posthog = usePostHog();
return (
<div className="grid gap-y-4">
<Form {...form}>
Expand All @@ -38,8 +192,31 @@ export const ProfileSettings = () => {
})}
>
<div className="flex flex-col gap-y-4">
<div>
<CurrentUserAvatar className="size-14" />
<div className="flex items-center gap-x-4">
<CurrentUserAvatar size={56} />
<div className="flex flex-col gap-y-2">
<div className="flex gap-2">
<ChangeAvatarButton
onSuccess={(imageKey) => {
refresh({ image: imageKey });
posthog?.capture("upload profile picture");
}}
/>
{user.image ? (
<RemoveAvatarButton
onSuccess={() => {
posthog?.capture("remove profile picture");
}}
/>
) : null}
</div>
<p className="text-muted-foreground text-xs">
<Trans
i18nKey="profilePictureDescription"
defaults="Up to 2MB, JPG or PNG"
/>
</p>
</div>
</div>
<FormField
control={control}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/user-dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const UserDropdown = ({ className }: { className?: string }) => {
className={cn("group min-w-0", className)}
>
<Button variant="ghost">
<CurrentUserAvatar className="size-6" />
<CurrentUserAvatar size={24} />
<span className="truncate">{user.name}</span>
<Icon>
<ChevronDownIcon />
Expand Down
Loading

0 comments on commit 32ba10b

Please sign in to comment.