diff --git a/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx b/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx index 9ac2616607..9679f29899 100644 --- a/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx +++ b/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx @@ -96,6 +96,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => isBrandingHidden: team?.hideBranding, isInstantMeeting: eventData.isInstantEvent && queryIsInstantMeeting ? true : false, themeBasis: null, + orgBannerUrl: eventData?.team?.parent?.bannerUrl ?? "", }, }; }; diff --git a/apps/web/pages/team/[slug]/[type].tsx b/apps/web/pages/team/[slug]/[type].tsx index 01ec4ad810..8b2ee43ad1 100644 --- a/apps/web/pages/team/[slug]/[type].tsx +++ b/apps/web/pages/team/[slug]/[type].tsx @@ -25,6 +25,7 @@ export const getMultipleDurationValue = ( if (multipleDurationConfig.includes(Number(queryDuration))) return Number(queryDuration); return defaultValue; }; + export default function Type({ slug, user, @@ -34,6 +35,7 @@ export default function Type({ isBrandingHidden, eventData, isInstantMeeting, + orgBannerUrl, }: PageProps) { const searchParams = useSearchParams(); @@ -66,6 +68,7 @@ export default function Type({ searchParams?.get("duration"), eventData.length )} + orgBannerUrl={orgBannerUrl} /> ); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 108f867f14..fdc8b0fc4b 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -251,6 +251,7 @@ "confirm_auth_change": "This will change the way you log in", "confirm_auth_email_change": "Changing the email address will disconnect your current authentication method to log in to Cal.com. We will ask you to verify your new email address. Moving forward, you will be logged out and use your new email address to log in instead of your current authentication method after setting your password by following the instructions that will be sent to your mail.", "reset_your_password": "Set your new password with the instructions sent to your email address.", + "org_banner_instructions":"Please upload image of {{width}} width and {{height}} height.", "email_change": "Log back in with your new email address and password.", "create_your_account": "Create your account", "create_your_calcom_account": "Create your Cal.com account", @@ -2083,6 +2084,7 @@ "organizations": "Organizations", "upload_cal_video_logo":"Upload Cal Video Logo", "update_cal_video_logo":"Update Cal Video Logo", + "upload_banner": "Upload Banner", "cal_video_logo_upload_instruction":"To ensure your logo is visible against Cal video's dark background, please upload a light-colored image in PNG or SVG format to maintain transparency.", "org_admin_other_teams": "Other teams", "org_admin_other_teams_description": "Here you can see teams inside your organization that you are not part of. You can add yourself to them if needed.", diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx index 554613ad94..a36551be90 100644 --- a/packages/features/bookings/Booker/Booker.tsx +++ b/packages/features/bookings/Booker/Booker.tsx @@ -66,6 +66,7 @@ const BookerComponent = ({ bookerLayout, schedule, verifyCode, + orgBannerUrl, }: BookerProps & WrappedBookerProps) => { const [bookerState, setBookerState] = useBookerStore((state) => [state.state, state.setState], shallow); const selectedDate = useBookerStore((state) => state.selectedDate); @@ -338,6 +339,15 @@ const BookerComponent = ({ + {orgBannerUrl && ( + org banner + )} + {layout !== BookerLayouts.MONTH_VIEW && !(layout === "mobile" && bookerState === "booking") && ( diff --git a/packages/features/bookings/Booker/types.ts b/packages/features/bookings/Booker/types.ts index 7644c1c347..0c851753c6 100644 --- a/packages/features/bookings/Booker/types.ts +++ b/packages/features/bookings/Booker/types.ts @@ -16,6 +16,7 @@ import type { GetBookingType } from "../lib/get-booking"; export interface BookerProps { eventSlug: string; username: string; + orgBannerUrl?: string | null; /** * Whether is a team or org, we gather basic info from both diff --git a/packages/features/ee/organizations/pages/settings/profile.tsx b/packages/features/ee/organizations/pages/settings/profile.tsx index 9639b39ca5..0bc2c91ab0 100644 --- a/packages/features/ee/organizations/pages/settings/profile.tsx +++ b/packages/features/ee/organizations/pages/settings/profile.tsx @@ -22,6 +22,7 @@ import { Button, Form, ImageUploader, + BannerUploader, Label, Meta, showToast, @@ -40,12 +41,14 @@ import { useOrgBranding } from "../../../organizations/context/provider"; const orgProfileFormSchema = z.object({ name: z.string(), logo: z.string().nullable(), + banner: z.string().nullable(), bio: z.string(), }); type FormValues = { name: string; logo: string | null; + banner: string | null; bio: string; slug: string; }; @@ -110,6 +113,7 @@ const OrgProfileView = () => { const defaultValues: FormValues = { name: currentOrganisation?.name || "", logo: currentOrganisation?.logo || "", + banner: currentOrganisation?.bannerUrl || "", bio: currentOrganisation?.bio || "", slug: currentOrganisation?.slug || @@ -178,6 +182,7 @@ const OrgProfileForm = ({ defaultValues }: { defaultValues: FormValues }) => { name: (res.data?.name || "") as string, bio: (res.data?.bio || "") as string, slug: defaultValues["slug"], + banner: (res.data?.bannerUrl || "") as string, }); await utils.viewer.teams.get.invalidate(); await utils.viewer.organizations.listCurrent.invalidate(); @@ -201,6 +206,7 @@ const OrgProfileForm = ({ defaultValues }: { defaultValues: FormValues }) => { name: values.name, slug: values.slug, bio: values.bio, + banner: values.banner, }; mutation.mutate(variables); @@ -250,6 +256,53 @@ const OrgProfileForm = ({ defaultValues }: { defaultValues: FormValues }) => { /> +
+ { + const showRemoveBannerButton = !!value; + + return ( + <> + +
+
+
+
+ + ); + }} + /> +
+ ()({ select: { slug: true, name: true, + bannerUrl: true, }, }, }, diff --git a/packages/prisma/migrations/20240214093418_add_banner_url/migration.sql b/packages/prisma/migrations/20240214093418_add_banner_url/migration.sql new file mode 100644 index 0000000000..2368e23663 --- /dev/null +++ b/packages/prisma/migrations/20240214093418_add_banner_url/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Team" ADD COLUMN "bannerUrl" TEXT; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 04058be485..068e1addad 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -348,6 +348,7 @@ model Team { brandColor String? darkBrandColor String? verifiedNumbers VerifiedNumber[] + bannerUrl String? parentId Int? parent Team? @relation("organization", fields: [parentId], references: [id], onDelete: Cascade) children Team[] @relation("organization") diff --git a/packages/trpc/server/routers/viewer/organizations/update.handler.ts b/packages/trpc/server/routers/viewer/organizations/update.handler.ts index 3170d92aad..b94eff59dd 100644 --- a/packages/trpc/server/routers/viewer/organizations/update.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/update.handler.ts @@ -1,8 +1,10 @@ import type { Prisma } from "@prisma/client"; +import { v4 as uuidv4 } from "uuid"; import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; import { getMetadataHelpers } from "@calcom/lib/getMetadataHelpers"; import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations"; +import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image"; import { uploadLogo } from "@calcom/lib/server/uploadLogo"; import { closeComUpdateTeam } from "@calcom/lib/sync/SyncServiceManager"; import { prisma } from "@calcom/prisma"; @@ -21,6 +23,30 @@ type UpdateOptions = { input: TUpdateInputSchema; }; +const uploadBanner = async ({ teamId, banner: data }: { teamId: number; banner: string }) => { + const objectKey = uuidv4(); + + await prisma.avatar.upsert({ + where: { + teamId_userId: { + teamId, + userId: 0, + }, + }, + create: { + teamId: teamId, + data, + objectKey, + }, + update: { + data, + objectKey, + }, + }); + + return `/api/avatar/${objectKey}.png`; +}; + export const updateHandler = async ({ ctx, input }: UpdateOptions) => { // A user can only have one org so we pass in their currentOrgId here const currentOrgId = ctx.user?.organization?.id || input.orgId; @@ -53,10 +79,23 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { metadata: true, name: true, slug: true, + bannerUrl: true, }, }); if (!prevOrganisation) throw new TRPCError({ code: "NOT_FOUND", message: "Organisation not found." }); + + let bannerUrl = prevOrganisation.bannerUrl; + if (input.banner && input.banner.startsWith("data:image/png;base64,")) { + const banner = await resizeBase64Image(input.banner, { maxSize: 1500 }); + bannerUrl = await uploadBanner({ + banner: banner, + teamId: currentOrgId, + }); + } else if (input.banner === "") { + bannerUrl = null; + } + const { mergeMetadata } = getMetadataHelpers(teamMetadataSchema.unwrap(), prevOrganisation.metadata); const data: Prisma.TeamUpdateArgs["data"] = { @@ -72,6 +111,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { weekStart: input.weekStart, timeFormat: input.timeFormat, metadata: mergeMetadata({ ...input.metadata }), + bannerUrl, }; if (input.logo && input.logo.startsWith("data:image/png;base64,")) { diff --git a/packages/trpc/server/routers/viewer/organizations/update.schema.ts b/packages/trpc/server/routers/viewer/organizations/update.schema.ts index 8b4731ffad..3cac63a882 100644 --- a/packages/trpc/server/routers/viewer/organizations/update.schema.ts +++ b/packages/trpc/server/routers/viewer/organizations/update.schema.ts @@ -22,6 +22,7 @@ export const ZUpdateInputSchema = z.object({ .optional() .nullable() .transform((v) => v || null), + banner: z.string().nullable().optional(), slug: z.string().optional(), hideBranding: z.boolean().optional(), hideBookATeamMember: z.boolean().optional(), diff --git a/packages/ui/components/image-uploader/BannerUploader.tsx b/packages/ui/components/image-uploader/BannerUploader.tsx new file mode 100644 index 0000000000..f744d0a14c --- /dev/null +++ b/packages/ui/components/image-uploader/BannerUploader.tsx @@ -0,0 +1,225 @@ +import { useCallback, useState, useEffect } from "react"; +import Cropper from "react-easy-crop"; + +import checkIfItFallbackImage from "@calcom/lib/checkIfItFallbackImage"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; + +import type { ButtonColor } from "../.."; +import { Button, Dialog, DialogClose, DialogContent, DialogTrigger, DialogFooter } from "../.."; +import { showToast } from "../toast"; +import { useFileReader, createImage, Slider } from "./Common"; +import type { FileEvent, Area } from "./Common"; + +type BannerUploaderProps = { + id: string; + buttonMsg: string; + handleAvatarChange: (imageSrc: string) => void; + imageSrc?: string; + target: string; + triggerButtonColor?: ButtonColor; + uploadInstruction?: string; + disabled?: boolean; + height: number; + width: number; +}; + +function CropContainer({ + onCropComplete, + imageSrc, +}: { + imageSrc: string; + onCropComplete: (croppedAreaPixels: Area) => void; +}) { + const { t } = useLocale(); + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + + const handleZoomSliderChange = (value: number) => { + value < 1 ? setZoom(1) : setZoom(value); + }; + + return ( +
+
+ onCropComplete(croppedAreaPixels)} + onZoomChange={setZoom} + /> +
+ +
+ ); +} + +export default function BannerUploader({ + target, + id, + buttonMsg, + handleAvatarChange, + triggerButtonColor, + imageSrc, + uploadInstruction, + disabled = false, + height, + width, +}: BannerUploaderProps) { + const { t } = useLocale(); + const [croppedAreaPixels, setCroppedAreaPixels] = useState(null); + + const [{ result }, setFile] = useFileReader({ + method: "readAsDataURL", + }); + + const onInputFile = async (e: FileEvent) => { + if (!e.target.files?.length) { + return; + } + + const limit = 5 * 1000000; // max limit 5mb + const file = e.target.files[0]; + + if (file.size > limit) { + showToast(t("image_size_limit_exceed"), "error"); + } else { + setFile(file); + } + }; + + const showCroppedImage = useCallback( + async (croppedAreaPixels: Area | null) => { + try { + if (!croppedAreaPixels) return; + const croppedImage = await getCroppedImg( + result as string /* result is always string when using readAsDataUrl */, + croppedAreaPixels, + height, + width + ); + handleAvatarChange(croppedImage); + } catch (e) { + console.error(e); + } + }, + [result, height, width, handleAvatarChange] + ); + + useEffect(() => { + const checkDimensions = async () => { + const image = await createImage( + result as string /* result is always string when using readAsDataUrl */ + ); + if (image.naturalWidth !== width || image.naturalHeight !== height) { + showToast(t("org_banner_instructions", { height, width }), "warning"); + } + }; + if (result) { + checkDimensions(); + } + }, [result]); + + return ( + { + // unset file on close + if (!opened) { + setFile(null); + } + }}> + + + + +
+
+ {!result && ( +
+ {!imageSrc || checkIfItFallbackImage(imageSrc) ? ( +

+ {t("no_target", { target })} +

+ ) : ( + // eslint-disable-next-line @next/next/no-img-element + {target} + )} +
+ )} + {result && } + + {uploadInstruction && ( +

({uploadInstruction})

+ )} +
+
+ + {t("cancel")} + showCroppedImage(croppedAreaPixels)}> + {t("save")} + + +
+
+ ); +} + +async function getCroppedImg( + imageSrc: string, + pixelCrop: Area, + height: number, + width: number +): Promise { + const image = await createImage(imageSrc); + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Context is null, this should never happen."); + + canvas.width = width; + canvas.height = height; + + ctx.drawImage( + image, + pixelCrop.x, + pixelCrop.y, + pixelCrop.width, + pixelCrop.height, + 0, + 0, + canvas.width, + canvas.height + ); + + return canvas.toDataURL("image/png"); +} diff --git a/packages/ui/components/image-uploader/Common.tsx b/packages/ui/components/image-uploader/Common.tsx new file mode 100644 index 0000000000..210f5b5b91 --- /dev/null +++ b/packages/ui/components/image-uploader/Common.tsx @@ -0,0 +1,88 @@ +import * as SliderPrimitive from "@radix-ui/react-slider"; +import { useEffect, useState } from "react"; +import type { FormEvent } from "react"; + +type ReadAsMethod = "readAsText" | "readAsDataURL" | "readAsArrayBuffer" | "readAsBinaryString"; + +type UseFileReaderProps = { + method: ReadAsMethod; + onLoad?: (result: unknown) => void; +}; + +export const useFileReader = (options: UseFileReaderProps) => { + const { method = "readAsText", onLoad } = options; + const [file, setFile] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + useEffect(() => { + if (!file && result) { + setResult(null); + } + }, [file, result]); + + useEffect(() => { + if (!file) { + return; + } + + const reader = new FileReader(); + reader.onloadstart = () => setLoading(true); + reader.onloadend = () => setLoading(false); + reader.onerror = () => setError(reader.error); + + reader.onload = (e: ProgressEvent) => { + setResult(e.target?.result ?? null); + if (onLoad) { + onLoad(e.target?.result ?? null); + } + }; + reader[method](file); + }, [file, method, onLoad]); + + return [{ result, error, file, loading }, setFile] as const; +}; + +export const createImage = (url: string) => + new Promise((resolve, reject) => { + const image = new Image(); + image.addEventListener("load", () => resolve(image)); + image.addEventListener("error", (error) => reject(error)); + image.setAttribute("crossOrigin", "anonymous"); // needed to avoid cross-origin issues on CodeSandbox + image.src = url; + }); + +export const Slider = ({ + value, + label, + changeHandler, + ...props +}: Omit & { + value: number; + label: string; + changeHandler: (value: number) => void; +}) => ( + changeHandler(value[0] ?? value)} + {...props}> + + + + + +); + +export interface FileEvent extends FormEvent { + target: EventTarget & T; +} + +export type Area = { + width: number; + height: number; + x: number; + y: number; +}; diff --git a/packages/ui/components/image-uploader/ImageUploader.tsx b/packages/ui/components/image-uploader/ImageUploader.tsx index 7764997c8c..f9abc562f3 100644 --- a/packages/ui/components/image-uploader/ImageUploader.tsx +++ b/packages/ui/components/image-uploader/ImageUploader.tsx @@ -1,6 +1,4 @@ -import * as SliderPrimitive from "@radix-ui/react-slider"; -import type { FormEvent } from "react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import Cropper from "react-easy-crop"; import checkIfItFallbackImage from "@calcom/lib/checkIfItFallbackImage"; @@ -9,58 +7,11 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { ButtonColor } from "../.."; import { Button, Dialog, DialogClose, DialogContent, DialogTrigger, DialogFooter } from "../.."; import { showToast } from "../toast"; - -type ReadAsMethod = "readAsText" | "readAsDataURL" | "readAsArrayBuffer" | "readAsBinaryString"; - -type UseFileReaderProps = { - method: ReadAsMethod; - onLoad?: (result: unknown) => void; -}; - -type Area = { - width: number; - height: number; - x: number; - y: number; -}; +import { useFileReader, createImage, Slider } from "./Common"; +import type { FileEvent, Area } from "./Common"; const MAX_IMAGE_SIZE = 512; -const useFileReader = (options: UseFileReaderProps) => { - const { method = "readAsText", onLoad } = options; - const [file, setFile] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [result, setResult] = useState(null); - - useEffect(() => { - if (!file && result) { - setResult(null); - } - }, [file, result]); - - useEffect(() => { - if (!file) { - return; - } - - const reader = new FileReader(); - reader.onloadstart = () => setLoading(true); - reader.onloadend = () => setLoading(false); - reader.onerror = () => setError(reader.error); - - reader.onload = (e: ProgressEvent) => { - setResult(e.target?.result ?? null); - if (onLoad) { - onLoad(e.target?.result ?? null); - } - }; - reader[method](file); - }, [file, method, onLoad]); - - return [{ result, error, file, loading }, setFile] as const; -}; - type ImageUploaderProps = { id: string; buttonMsg: string; @@ -72,10 +23,6 @@ type ImageUploaderProps = { disabled?: boolean; }; -interface FileEvent extends FormEvent { - target: EventTarget & T; -} - // This is separate to prevent loading the component until file upload function CropContainer({ onCropComplete, @@ -231,15 +178,6 @@ export default function ImageUploader({ ); } -const createImage = (url: string) => - new Promise((resolve, reject) => { - const image = new Image(); - image.addEventListener("load", () => resolve(image)); - image.addEventListener("error", (error) => reject(error)); - image.setAttribute("crossOrigin", "anonymous"); // needed to avoid cross-origin issues on CodeSandbox - image.src = url; - }); - async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise { const image = await createImage(imageSrc); const canvas = document.createElement("canvas"); @@ -248,6 +186,7 @@ async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise const maxSize = Math.max(image.naturalWidth, image.naturalHeight); const resizeRatio = MAX_IMAGE_SIZE / maxSize < 1 ? Math.max(MAX_IMAGE_SIZE / maxSize, 0.75) : 1; + // huh, what? - Having this turned off actually improves image quality as otherwise anti-aliasing is applied // this reduces the quality of the image overall because it anti-aliases the existing, copied image; blur results ctx.imageSmoothingEnabled = false; @@ -279,26 +218,3 @@ async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise return canvas.toDataURL("image/png"); } - -const Slider = ({ - value, - label, - changeHandler, - ...props -}: Omit & { - value: number; - label: string; - changeHandler: (value: number) => void; -}) => ( - changeHandler(value[0] ?? value)} - {...props}> - - - - - -); diff --git a/packages/ui/components/image-uploader/index.ts b/packages/ui/components/image-uploader/index.ts index 3ac324a4dd..fc63591bbc 100644 --- a/packages/ui/components/image-uploader/index.ts +++ b/packages/ui/components/image-uploader/index.ts @@ -1 +1,2 @@ export { default as ImageUploader } from "./ImageUploader"; +export { default as BannerUploader } from "./BannerUploader"; diff --git a/packages/ui/index.tsx b/packages/ui/index.tsx index 19c8a9abc0..687877ee4a 100644 --- a/packages/ui/index.tsx +++ b/packages/ui/index.tsx @@ -151,6 +151,8 @@ export { export { default as MultiSelectCheckboxes } from "./components/form/checkbox/MultiSelectCheckboxes"; export type { Option as MultiSelectCheckboxesOptionType } from "./components/form/checkbox/MultiSelectCheckboxes"; export { default as ImageUploader } from "./components/image-uploader/ImageUploader"; +export { default as BannerUploader } from "./components/image-uploader/BannerUploader"; + export type { ButtonColor } from "./components/button/Button"; export { CreateButton, CreateButtonWithTeamsList } from "./components/createButton";