diff --git a/apps/web/package.json b/apps/web/package.json index 672379c3b..5ef3d2c98 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/public/locales/en/app.json b/apps/web/public/locales/en/app.json index ae70bbf78..3406e9eca 100644 --- a/apps/web/public/locales/en/app.json +++ b/apps/web/public/locales/en/app.json @@ -273,5 +273,14 @@ "timeZoneChangeDetectorMessage": "Your timezone has changed to {currentTimeZone}. 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." } diff --git a/apps/web/src/app/[locale]/(admin)/sidebar.tsx b/apps/web/src/app/[locale]/(admin)/sidebar.tsx index 35647c830..cc0490918 100644 --- a/apps/web/src/app/[locale]/(admin)/sidebar.tsx +++ b/apps/web/src/app/[locale]/(admin)/sidebar.tsx @@ -161,7 +161,7 @@ export function Sidebar() { >
- +
{user.name} diff --git a/apps/web/src/app/api/storage/[...key]/route.ts b/apps/web/src/app/api/storage/[...key]/route.ts new file mode 100644 index 000000000..8f04f62e3 --- /dev/null +++ b/apps/web/src/app/api/storage/[...key]/route.ts @@ -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 }, + ); + } +} diff --git a/apps/web/src/components/current-user-avatar.tsx b/apps/web/src/components/current-user-avatar.tsx index ed8c9902a..54ba8f7d2 100644 --- a/apps/web/src/components/current-user-avatar.tsx +++ b/apps/web/src/components/current-user-avatar.tsx @@ -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 ( - - - {user.name[0]} + + {user.image ? ( + {user.name} + ) : ( + {user.name[0]} + )} ); }; diff --git a/apps/web/src/components/settings/language-preference.tsx b/apps/web/src/components/settings/language-preference.tsx index 58f1ac935..d48257d16 100644 --- a/apps/web/src/components/settings/language-preference.tsx +++ b/apps/web/src/components/settings/language-preference.tsx @@ -3,7 +3,6 @@ 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"; @@ -11,7 +10,7 @@ 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(), @@ -28,18 +27,15 @@ export const LanguagePreference = () => { language: i18n.language, }, }); - - const updatePreferences = trpc.user.updatePreferences.useMutation(); - const session = useSession(); + const { updatePreferences } = usePreferences(); return (
{ if (!user.isGuest) { - await updatePreferences.mutateAsync({ locale: data.language }); + await updatePreferences({ locale: data.language }); } - await session.update({ locale: data.language }); router.refresh(); })} > diff --git a/apps/web/src/components/settings/profile-settings.tsx b/apps/web/src/components/settings/profile-settings.tsx index 3148082a2..a26dcc3b9 100644 --- a/apps/web/src/components/settings/profile-settings.tsx +++ b/apps/web/src/components/settings/profile-settings.tsx @@ -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, + ) => { + 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 ( + <> + + + + ); +} + +function RemoveAvatarButton({ onSuccess }: { onSuccess?: () => void }) { + const { refresh } = useUser(); + const removeAvatarMutation = trpc.user.removeAvatar.useMutation({ + onSuccess: () => { + refresh({ image: null }); + onSuccess?.(); + }, + }); + + return ( + + ); +} export const ProfileSettings = () => { const { user, refresh } = useUser(); @@ -27,7 +181,7 @@ export const ProfileSettings = () => { }); const { control, handleSubmit, formState, reset } = form; - + const posthog = usePostHog(); return (
@@ -38,8 +192,31 @@ export const ProfileSettings = () => { })} >
-
- +
+ +
+
+ { + refresh({ image: imageKey }); + posthog?.capture("upload profile picture"); + }} + /> + {user.image ? ( + { + posthog?.capture("remove profile picture"); + }} + /> + ) : null} +
+

+ +

+
{ className={cn("group min-w-0", className)} >