mirror of
https://github.com/lukevella/rallly.git
synced 2025-08-06 09:59:00 +02:00
✨ Custom Profile Pictures (#1339)
This commit is contained in:
parent
1276bc27b3
commit
e723d9a933
12 changed files with 383 additions and 376 deletions
|
@ -17,9 +17,6 @@ const nextConfig = {
|
||||||
output:
|
output:
|
||||||
process.env.NEXT_PUBLIC_SELF_HOSTED === "true" ? "standalone" : undefined,
|
process.env.NEXT_PUBLIC_SELF_HOSTED === "true" ? "standalone" : undefined,
|
||||||
productionBrowserSourceMaps: true,
|
productionBrowserSourceMaps: true,
|
||||||
images: {
|
|
||||||
domains: [process.env.NEXT_PUBLIC_BASE_URL || "localhost:3000"],
|
|
||||||
},
|
|
||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
"@rallly/database",
|
"@rallly/database",
|
||||||
"@rallly/icons",
|
"@rallly/icons",
|
||||||
|
@ -54,6 +51,10 @@ const nextConfig = {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
experimental: {
|
||||||
|
// necessary for server actions using aws-sdk
|
||||||
|
serverComponentsExternalPackages: ["@aws-sdk"],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const sentryWebpackPluginOptions = {
|
const sentryWebpackPluginOptions = {
|
||||||
|
|
|
@ -275,12 +275,12 @@
|
||||||
"noKeepCurrentTimezone": "No, keep the current timezone",
|
"noKeepCurrentTimezone": "No, keep the current timezone",
|
||||||
"annualBenefit": "{count} months free",
|
"annualBenefit": "{count} months free",
|
||||||
"removeAvatar": "Remove",
|
"removeAvatar": "Remove",
|
||||||
"featureNotAvailable": "Feature not available",
|
|
||||||
"featureNotAvailableDescription": "This feature requires object storage to be enabled.",
|
|
||||||
"uploadProfilePicture": "Upload",
|
"uploadProfilePicture": "Upload",
|
||||||
"profilePictureDescription": "Up to 2MB, JPG or PNG",
|
"profilePictureDescription": "Up to 2MB, JPG or PNG",
|
||||||
"invalidFileType": "Invalid file type",
|
"invalidFileType": "Invalid file type",
|
||||||
"invalidFileTypeDescription": "Please upload a JPG or PNG file.",
|
"invalidFileTypeDescription": "Please upload a JPG or PNG file.",
|
||||||
"fileTooLarge": "File too large",
|
"fileTooLarge": "File too large",
|
||||||
"fileTooLargeDescription": "Please upload a file smaller than 2MB."
|
"fileTooLargeDescription": "Please upload a file smaller than 2MB.",
|
||||||
|
"errorUploadPicture": "Failed to upload",
|
||||||
|
"errorUploadPictureDescription": "There was an issue uploading your picture. Please try again later."
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,8 +10,8 @@ import Link from "next/link";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
|
|
||||||
import { DeleteAccountDialog } from "@/app/[locale]/(admin)/settings/profile/delete-account-dialog";
|
import { DeleteAccountDialog } from "@/app/[locale]/(admin)/settings/profile/delete-account-dialog";
|
||||||
|
import { ProfileSettings } from "@/app/[locale]/(admin)/settings/profile/profile-settings";
|
||||||
import { LogoutButton } from "@/app/components/logout-button";
|
import { LogoutButton } from "@/app/components/logout-button";
|
||||||
import { ProfileSettings } from "@/components/settings/profile-settings";
|
|
||||||
import {
|
import {
|
||||||
Settings,
|
Settings,
|
||||||
SettingsContent,
|
SettingsContent,
|
||||||
|
|
|
@ -0,0 +1,198 @@
|
||||||
|
import { Button } from "@rallly/ui/button";
|
||||||
|
import { useToast } from "@rallly/ui/hooks/use-toast";
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
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 { useAvatarsEnabled } from "@/features/avatars";
|
||||||
|
import { usePostHog } from "@/utils/posthog";
|
||||||
|
import { trpc } from "@/utils/trpc/client";
|
||||||
|
|
||||||
|
const allowedMimeTypes = z.enum(["image/jpeg", "image/png"]);
|
||||||
|
|
||||||
|
function ChangeAvatarButton({ onSuccess }: { onSuccess: () => void }) {
|
||||||
|
const getPresignedUrl = trpc.user.getAvatarUploadUrl.useMutation();
|
||||||
|
const updateAvatar = trpc.user.updateAvatar.useMutation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
|
const handleFileChange = async (
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const parsedFileType = allowedMimeTypes.safeParse(file.type);
|
||||||
|
|
||||||
|
if (!parsedFileType.success) {
|
||||||
|
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: file.type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileType = parsedFileType.data;
|
||||||
|
|
||||||
|
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 getPresignedUrl.mutateAsync({
|
||||||
|
fileType,
|
||||||
|
fileSize: file.size,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { url, fields } = res;
|
||||||
|
|
||||||
|
await fetch(url, {
|
||||||
|
method: "PUT",
|
||||||
|
body: file,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": fileType,
|
||||||
|
"Content-Length": file.size.toString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateAvatar.mutateAsync({
|
||||||
|
imageKey: fields.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
onSuccess();
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t("errorUploadPicture", {
|
||||||
|
defaultValue: "Failed to upload",
|
||||||
|
}),
|
||||||
|
description: t("errorUploadPictureDescription", {
|
||||||
|
defaultValue:
|
||||||
|
"There was an issue uploading your picture. Please try again later.",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
Sentry.captureException(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 [isLoading, setLoading] = React.useState(false);
|
||||||
|
const removeAvatar = trpc.user.removeAvatar.useMutation();
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
loading={isLoading}
|
||||||
|
variant="ghost"
|
||||||
|
onClick={async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await removeAvatar.mutateAsync();
|
||||||
|
onSuccess?.();
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans i18nKey="removeAvatar" defaults="Remove" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Upload() {
|
||||||
|
const { user, refresh } = useUser();
|
||||||
|
const isAvatarsEnabled = useAvatarsEnabled();
|
||||||
|
|
||||||
|
const posthog = usePostHog();
|
||||||
|
|
||||||
|
if (!isAvatarsEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<ChangeAvatarButton
|
||||||
|
onSuccess={() => {
|
||||||
|
refresh();
|
||||||
|
posthog?.capture("upload profile picture");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{user.image ? (
|
||||||
|
<RemoveAvatarButton
|
||||||
|
onSuccess={() => {
|
||||||
|
refresh();
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfilePicture() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<CurrentUserAvatar size={56} />
|
||||||
|
<Upload />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { Button } from "@rallly/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
} from "@rallly/ui/form";
|
||||||
|
import { Input } from "@rallly/ui/input";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
import { ProfilePicture } from "@/app/[locale]/(admin)/settings/profile/profile-picture";
|
||||||
|
import { Trans } from "@/components/trans";
|
||||||
|
import { useUser } from "@/components/user-provider";
|
||||||
|
|
||||||
|
export const ProfileSettings = () => {
|
||||||
|
const { user, refresh } = useUser();
|
||||||
|
|
||||||
|
const form = useForm<{
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
}>({
|
||||||
|
defaultValues: {
|
||||||
|
name: user.isGuest ? "" : user.name,
|
||||||
|
email: user.email ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { control, handleSubmit, formState, reset } = form;
|
||||||
|
return (
|
||||||
|
<div className="grid gap-y-4">
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(async (data) => {
|
||||||
|
await refresh({ name: data.name });
|
||||||
|
reset(data);
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-y-4">
|
||||||
|
<ProfilePicture />
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel htmlFor="name">
|
||||||
|
<Trans i18nKey="name" />
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input id="name" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans i18nKey="email" />
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} disabled={true} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="mt-4 flex">
|
||||||
|
<Button
|
||||||
|
loading={formState.isSubmitting}
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={!formState.isDirty}
|
||||||
|
>
|
||||||
|
<Trans i18nKey="save" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,5 +1,4 @@
|
||||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
|
@ -37,6 +36,7 @@ export async function GET(
|
||||||
context: { params: { key: string[] } },
|
context: { params: { key: string[] } },
|
||||||
) {
|
) {
|
||||||
const imageKey = context.params.key.join("/");
|
const imageKey = context.params.key.join("/");
|
||||||
|
|
||||||
if (!imageKey) {
|
if (!imageKey) {
|
||||||
return new Response("No key provided", { status: 400 });
|
return new Response("No key provided", { status: 400 });
|
||||||
}
|
}
|
||||||
|
@ -50,7 +50,7 @@ export async function GET(
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Sentry.captureException(error);
|
console.error(error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Failed to fetch object" },
|
{ error: "Failed to fetch object" },
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
|
|
|
@ -1,58 +0,0 @@
|
||||||
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) {
|
|
||||||
const imageKey = req.nextUrl.searchParams.get("key");
|
|
||||||
|
|
||||||
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,
|
|
||||||
"Content-Length": buffer.length.toString(),
|
|
||||||
"Cache-Control": "max-age=86400",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
Sentry.captureException(error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to fetch object" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,17 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@rallly/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@rallly/ui/avatar";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
function getAvatarUrl(imageKey: string) {
|
import { useAvatarsEnabled } from "@/features/avatars";
|
||||||
// Some users have avatars that come from external providers (e.g. Google).
|
|
||||||
if (imageKey.startsWith("https://")) {
|
|
||||||
return imageKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `/api/storage?key=${encodeURIComponent(imageKey)}`;
|
export function OptimizedAvatarImage({
|
||||||
}
|
|
||||||
|
|
||||||
export const OptimizedAvatarImage = ({
|
|
||||||
size,
|
size,
|
||||||
className,
|
className,
|
||||||
src,
|
src,
|
||||||
|
@ -21,21 +15,28 @@ export const OptimizedAvatarImage = ({
|
||||||
src?: string;
|
src?: string;
|
||||||
name: string;
|
name: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) => {
|
}) {
|
||||||
|
const isAvatarsEnabled = useAvatarsEnabled();
|
||||||
|
const [isLoaded, setLoaded] = React.useState(false);
|
||||||
return (
|
return (
|
||||||
<Avatar className={className} style={{ width: size, height: size }}>
|
<Avatar className={className} style={{ width: size, height: size }}>
|
||||||
{!src || src.startsWith("https") ? (
|
{src ? (
|
||||||
|
src.startsWith("https") ? (
|
||||||
<AvatarImage src={src} alt={name} />
|
<AvatarImage src={src} alt={name} />
|
||||||
) : (
|
) : isAvatarsEnabled ? (
|
||||||
<Image
|
<Image
|
||||||
src={getAvatarUrl(src)}
|
src={`/api/storage/${src}`}
|
||||||
width={128}
|
width={128}
|
||||||
height={128}
|
height={128}
|
||||||
alt={name}
|
alt={name}
|
||||||
style={{ objectFit: "cover" }}
|
style={{ objectFit: "cover" }}
|
||||||
|
onLoad={() => {
|
||||||
|
setLoaded(true);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
) : null
|
||||||
<AvatarFallback>{name[0]}</AvatarFallback>
|
) : null}
|
||||||
|
{!src || !isLoaded ? <AvatarFallback>{name[0]}</AvatarFallback> : null}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
|
@ -1,264 +0,0 @@
|
||||||
import { Button } from "@rallly/ui/button";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
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();
|
|
||||||
|
|
||||||
const form = useForm<{
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
}>({
|
|
||||||
defaultValues: {
|
|
||||||
name: user.isGuest ? "" : user.name,
|
|
||||||
email: user.email ?? "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { control, handleSubmit, formState, reset } = form;
|
|
||||||
const posthog = usePostHog();
|
|
||||||
return (
|
|
||||||
<div className="grid gap-y-4">
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit(async (data) => {
|
|
||||||
await refresh({ name: data.name });
|
|
||||||
reset(data);
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-y-4">
|
|
||||||
<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}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel htmlFor="name">
|
|
||||||
<Trans i18nKey="name" />
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input id="name" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="email"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans i18nKey="email" />
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} disabled={true} />
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="mt-4 flex">
|
|
||||||
<Button
|
|
||||||
loading={formState.isSubmitting}
|
|
||||||
variant="primary"
|
|
||||||
type="submit"
|
|
||||||
disabled={!formState.isDirty}
|
|
||||||
>
|
|
||||||
<Trans i18nKey="save" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
5
apps/web/src/features/avatars.ts
Normal file
5
apps/web/src/features/avatars.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import { useFeatureFlagEnabled } from "posthog-js/react";
|
||||||
|
|
||||||
|
export function useAvatarsEnabled() {
|
||||||
|
return useFeatureFlagEnabled("avatars");
|
||||||
|
}
|
|
@ -17,6 +17,11 @@ import {
|
||||||
router,
|
router,
|
||||||
} from "../trpc";
|
} from "../trpc";
|
||||||
|
|
||||||
|
const mimeToExtension = {
|
||||||
|
"image/jpeg": "jpg",
|
||||||
|
"image/png": "png",
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const user = router({
|
export const user = router({
|
||||||
getBilling: possiblyPublicProcedure.query(async ({ ctx }) => {
|
getBilling: possiblyPublicProcedure.query(async ({ ctx }) => {
|
||||||
return await prisma.userPaymentData.findUnique({
|
return await prisma.userPaymentData.findUnique({
|
||||||
|
@ -147,19 +152,24 @@ export const user = router({
|
||||||
}),
|
}),
|
||||||
getAvatarUploadUrl: privateProcedure
|
getAvatarUploadUrl: privateProcedure
|
||||||
.use(rateLimitMiddleware)
|
.use(rateLimitMiddleware)
|
||||||
.input(z.object({ fileType: z.string(), fileSize: z.number() }))
|
.input(
|
||||||
|
z.object({
|
||||||
|
fileType: z.enum(["image/jpeg", "image/png"]),
|
||||||
|
fileSize: z.number(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const s3Client = getS3Client();
|
const s3Client = getS3Client();
|
||||||
|
|
||||||
if (!s3Client) {
|
if (!s3Client) {
|
||||||
return {
|
throw new TRPCError({
|
||||||
success: false,
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
cause: "object-storage-not-enabled",
|
message: "S3 storage has not been configured",
|
||||||
} as const;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = ctx.user.id;
|
const userId = ctx.user.id;
|
||||||
const key = `avatars/${userId}-${Date.now()}.jpg`;
|
const key = `avatars/${userId}-${Date.now()}.${mimeToExtension[input.fileType]}`;
|
||||||
|
|
||||||
if (input.fileSize > 2 * 1024 * 1024) {
|
if (input.fileSize > 2 * 1024 * 1024) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|
|
@ -253,32 +253,62 @@ const getAuthOptions = (...args: GetServerSessionParams) =>
|
||||||
await mergeGuestsIntoUser(user.id, [account.providerAccountId]);
|
await mergeGuestsIntoUser(user.id, [account.providerAccountId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user) {
|
|
||||||
token.locale = user.locale;
|
|
||||||
token.timeFormat = user.timeFormat;
|
|
||||||
token.timeZone = user.timeZone;
|
|
||||||
token.weekStart = user.weekStart;
|
|
||||||
token.picture = user.image;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
token.locale = session.locale;
|
token.locale = session.locale;
|
||||||
token.timeFormat = session.timeFormat;
|
token.timeFormat = session.timeFormat;
|
||||||
token.timeZone = session.timeZone;
|
token.timeZone = session.timeZone;
|
||||||
token.weekStart = session.weekStart;
|
token.weekStart = session.weekStart;
|
||||||
token.picture = session.image;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return token;
|
return token;
|
||||||
},
|
},
|
||||||
async session({ session, token }) {
|
async session({ session, token }) {
|
||||||
|
if (token.sub?.startsWith("user-")) {
|
||||||
session.user.id = token.sub as string;
|
session.user.id = token.sub as string;
|
||||||
session.user.name = token.name;
|
session.user.locale = token.locale;
|
||||||
session.user.timeFormat = token.timeFormat;
|
session.user.timeFormat = token.timeFormat;
|
||||||
session.user.timeZone = token.timeZone;
|
session.user.timeZone = token.timeZone;
|
||||||
session.user.locale = token.locale;
|
session.user.locale = token.locale;
|
||||||
session.user.weekStart = token.weekStart;
|
session.user.weekStart = token.weekStart;
|
||||||
session.user.image = token.picture;
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
id: token.sub as string,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
timeFormat: true,
|
||||||
|
timeZone: true,
|
||||||
|
locale: true,
|
||||||
|
weekStart: true,
|
||||||
|
email: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
session.user.id = token.sub as string;
|
||||||
|
session.user.email = token.email;
|
||||||
|
session.user.locale = token.locale;
|
||||||
|
session.user.timeFormat = token.timeFormat;
|
||||||
|
session.user.timeZone = token.timeZone;
|
||||||
|
session.user.locale = token.locale;
|
||||||
|
session.user.weekStart = token.weekStart;
|
||||||
|
} else {
|
||||||
|
session.user.id = user.id;
|
||||||
|
session.user.name = user.name;
|
||||||
|
session.user.email = user.email;
|
||||||
|
session.user.locale = user.locale;
|
||||||
|
session.user.timeFormat = user.timeFormat;
|
||||||
|
session.user.timeZone = user.timeZone;
|
||||||
|
session.user.locale = user.locale;
|
||||||
|
session.user.weekStart = user.weekStart;
|
||||||
|
session.user.image = user.image;
|
||||||
|
}
|
||||||
|
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue