mirror of
https://github.com/lukevella/rallly.git
synced 2025-08-01 07:28:35 +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:
|
||||
process.env.NEXT_PUBLIC_SELF_HOSTED === "true" ? "standalone" : undefined,
|
||||
productionBrowserSourceMaps: true,
|
||||
images: {
|
||||
domains: [process.env.NEXT_PUBLIC_BASE_URL || "localhost:3000"],
|
||||
},
|
||||
transpilePackages: [
|
||||
"@rallly/database",
|
||||
"@rallly/icons",
|
||||
|
@ -54,6 +51,10 @@ const nextConfig = {
|
|||
},
|
||||
];
|
||||
},
|
||||
experimental: {
|
||||
// necessary for server actions using aws-sdk
|
||||
serverComponentsExternalPackages: ["@aws-sdk"],
|
||||
},
|
||||
};
|
||||
|
||||
const sentryWebpackPluginOptions = {
|
||||
|
|
|
@ -275,12 +275,12 @@
|
|||
"noKeepCurrentTimezone": "No, keep the current timezone",
|
||||
"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."
|
||||
"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 { 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 { ProfileSettings } from "@/components/settings/profile-settings";
|
||||
import {
|
||||
Settings,
|
||||
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 * as Sentry from "@sentry/nextjs";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { env } from "@/env";
|
||||
|
@ -37,6 +36,7 @@ export async function GET(
|
|||
context: { params: { key: string[] } },
|
||||
) {
|
||||
const imageKey = context.params.key.join("/");
|
||||
|
||||
if (!imageKey) {
|
||||
return new Response("No key provided", { status: 400 });
|
||||
}
|
||||
|
@ -50,7 +50,7 @@ export async function GET(
|
|||
},
|
||||
});
|
||||
} catch (error) {
|
||||
Sentry.captureException(error);
|
||||
console.error(error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch object" },
|
||||
{ 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";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@rallly/ui/avatar";
|
||||
import Image from "next/image";
|
||||
import React from "react";
|
||||
|
||||
function getAvatarUrl(imageKey: string) {
|
||||
// Some users have avatars that come from external providers (e.g. Google).
|
||||
if (imageKey.startsWith("https://")) {
|
||||
return imageKey;
|
||||
}
|
||||
import { useAvatarsEnabled } from "@/features/avatars";
|
||||
|
||||
return `/api/storage?key=${encodeURIComponent(imageKey)}`;
|
||||
}
|
||||
|
||||
export const OptimizedAvatarImage = ({
|
||||
export function OptimizedAvatarImage({
|
||||
size,
|
||||
className,
|
||||
src,
|
||||
|
@ -21,21 +15,28 @@ export const OptimizedAvatarImage = ({
|
|||
src?: string;
|
||||
name: string;
|
||||
className?: string;
|
||||
}) => {
|
||||
}) {
|
||||
const isAvatarsEnabled = useAvatarsEnabled();
|
||||
const [isLoaded, setLoaded] = React.useState(false);
|
||||
return (
|
||||
<Avatar className={className} style={{ width: size, height: size }}>
|
||||
{!src || src.startsWith("https") ? (
|
||||
<AvatarImage src={src} alt={name} />
|
||||
) : (
|
||||
<Image
|
||||
src={getAvatarUrl(src)}
|
||||
width={128}
|
||||
height={128}
|
||||
alt={name}
|
||||
style={{ objectFit: "cover" }}
|
||||
/>
|
||||
)}
|
||||
<AvatarFallback>{name[0]}</AvatarFallback>
|
||||
{src ? (
|
||||
src.startsWith("https") ? (
|
||||
<AvatarImage src={src} alt={name} />
|
||||
) : isAvatarsEnabled ? (
|
||||
<Image
|
||||
src={`/api/storage/${src}`}
|
||||
width={128}
|
||||
height={128}
|
||||
alt={name}
|
||||
style={{ objectFit: "cover" }}
|
||||
onLoad={() => {
|
||||
setLoaded(true);
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
) : null}
|
||||
{!src || !isLoaded ? <AvatarFallback>{name[0]}</AvatarFallback> : null}
|
||||
</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,
|
||||
} from "../trpc";
|
||||
|
||||
const mimeToExtension = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
} as const;
|
||||
|
||||
export const user = router({
|
||||
getBilling: possiblyPublicProcedure.query(async ({ ctx }) => {
|
||||
return await prisma.userPaymentData.findUnique({
|
||||
|
@ -147,19 +152,24 @@ export const user = router({
|
|||
}),
|
||||
getAvatarUploadUrl: privateProcedure
|
||||
.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 }) => {
|
||||
const s3Client = getS3Client();
|
||||
|
||||
if (!s3Client) {
|
||||
return {
|
||||
success: false,
|
||||
cause: "object-storage-not-enabled",
|
||||
} as const;
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "S3 storage has not been configured",
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
throw new TRPCError({
|
||||
|
|
|
@ -253,32 +253,62 @@ const getAuthOptions = (...args: GetServerSessionParams) =>
|
|||
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) {
|
||||
token.locale = session.locale;
|
||||
token.timeFormat = session.timeFormat;
|
||||
token.timeZone = session.timeZone;
|
||||
token.weekStart = session.weekStart;
|
||||
token.picture = session.image;
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
session.user.id = token.sub as string;
|
||||
session.user.name = token.name;
|
||||
session.user.timeFormat = token.timeFormat;
|
||||
session.user.timeZone = token.timeZone;
|
||||
session.user.locale = token.locale;
|
||||
session.user.weekStart = token.weekStart;
|
||||
session.user.image = token.picture;
|
||||
if (token.sub?.startsWith("user-")) {
|
||||
session.user.id = token.sub as string;
|
||||
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;
|
||||
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;
|
||||
},
|
||||
},
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue