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[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 (